From ba5cdf77a18fc5160bdc12928d34850865a97a03 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 17 Jun 2026 23:25:20 +0100 Subject: [PATCH 1/4] feat(compile): add internal supply-chain feed/registry mirror Add an optional supply-chain: front-matter section that reroutes the four GitHub/GHCR fetches (ado-aw compiler, AWF binary, ado-script bundle, AWF/MCPG images) to an internal Azure DevOps Artifacts feed and/or container registry. Default path is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 16 +- docs/front-matter.md | 8 + docs/supply-chain.md | 170 ++++++++++++ site/astro.config.mjs | 1 + .../content/docs/reference/supply-chain.mdx | 105 ++++++++ src/compile/agentic_pipeline.rs | 250 +++++++++++++++--- src/compile/extensions/ado_script.rs | 79 +++++- src/compile/extensions/mod.rs | 1 + src/compile/types.rs | 245 +++++++++++++++++ src/secure.rs | 28 ++ src/validate.rs | 60 +++++ tests/bash_lint_tests.rs | 1 + tests/compiler_tests.rs | 202 ++++++++++++++ tests/fixtures/supply-chain-agent.md | 16 ++ 14 files changed, 1142 insertions(+), 40 deletions(-) create mode 100644 docs/supply-chain.md create mode 100644 site/src/content/docs/reference/supply-chain.mdx create mode 100644 tests/fixtures/supply-chain-agent.md diff --git a/AGENTS.md b/AGENTS.md index f479285c..1381ecbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -310,6 +310,12 @@ index to jump to the right page. - [`docs/ado-aw-debug.md`](docs/ado-aw-debug.md) — debug-only `ado-aw-debug:` front-matter section (`skip-integrity`, `create-issue` for filing GitHub issues from dogfood pipelines). NOT a regular safe-output. +- [`docs/supply-chain.md`](docs/supply-chain.md) — optional `supply-chain:` + front-matter section that mirrors the compiler, AWF binary, ado-script + bundle, and AWF/MCPG images from an internal Azure DevOps Artifacts feed + and/or container registry (NuGet `DownloadPackage@1` + ACR `az acr login`), + with asymmetric auth (feed defaults to `$(System.AccessToken)`; registry + requires a service connection). ### Compiler internals & operations @@ -389,7 +395,15 @@ Following the gh-aw security model: 1. **Safe Outputs**: Only allow write operations through sanitized safe-output declarations — see [`docs/safe-outputs.md`](docs/safe-outputs.md). 2. **Network Isolation**: Pipelines run in OneBranch's network-isolated - environment via AWF — see [`docs/network.md`](docs/network.md). + environment via AWF — see [`docs/network.md`](docs/network.md). **Scope + note:** AWF's L7 allowlist wraps *only* the agent's copilot command + (`awf … --allow-domains … -- ''` in + `src/compile/agentic_pipeline.rs::run_agent_step`). All other ADO steps — + binary/bundle downloads, `docker pull`, ACR/NuGet auth (including the + `supply-chain:` mirror fetches) — run *outside* the sandbox with the build + agent pool's normal network, so they do **not** need entries in the AWF + allowlist. Air-gapping the build agent itself from GitHub/GHCR is the agent + pool's network policy, not AWF. 3. **Tool Allow-listing**: Agents have access to a limited, controlled set of tools — see [`docs/tools.md`](docs/tools.md) and [`docs/mcp.md`](docs/mcp.md). diff --git a/docs/front-matter.md b/docs/front-matter.md index f0db8b38..f243ce84 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -172,6 +172,14 @@ permissions: # optional ADO access token configuration # Default: executor uses $(System.AccessToken). # Set this only for cross-org writes or # named-identity attribution. +supply-chain: # optional internal supply-chain mirror (see docs/supply-chain.md) + feed: # mirror binaries (compiler, AWF, ado-script) from an ADO Artifacts feed + name: my-project/my-feed # feed name or project/feed; scalar `feed: my-feed` shorthand also works + service-connection: feed-conn # optional; omit for same-org feeds (uses $(System.AccessToken)) + registry: # mirror AWF/MCPG images from an internal ACR + name: myacr.azurecr.io # ACR login server + service-connection: acr-conn # REQUIRED when registry is set (ACR has no System.AccessToken path) + service-connection: shared-conn # optional shared fallback for whichever target omits its own parameters: # optional ADO runtime parameters (surfaced in UI when queuing a run) - name: clearMemory displayName: "Clear agent memory" diff --git a/docs/supply-chain.md b/docs/supply-chain.md new file mode 100644 index 00000000..fbf76469 --- /dev/null +++ b/docs/supply-chain.md @@ -0,0 +1,170 @@ +# Internal Supply-Chain Mirror (`supply-chain:`) + +By default a compiled agentic pipeline fetches four kinds of artifact from +GitHub / GHCR at run time: + +| # | Artifact | Default source | +|---|----------|----------------| +| 1 | `ado-aw` compiler (`ado-aw-linux-x64`) | `github.com/githubnext/ado-aw` releases | +| 2 | AWF firewall (`awf-linux-x64`) | `github.com/github/gh-aw-firewall` releases | +| 3 | `ado-script.zip` bundle | `github.com/githubnext/ado-aw` releases | +| 4 | AWF + MCPG container images | `ghcr.io/github/...` | + +The optional `supply-chain:` front-matter section reroutes these fetches to an +**internal Azure DevOps Artifacts feed** (for the binaries #1–#3) and/or an +**internal container registry** (for the images #4). This is intended for +supply-chain-hardened environments where the build agent pool cannot reach +GitHub / GHCR. + +When `supply-chain:` is omitted, the generated pipeline is byte-for-byte +identical to before — there is no behavioural change for existing agents. + +## Configuration + +```yaml +supply-chain: + feed: # mirrors binaries #1, #2, #3 + name: my-project/my-feed # feed name or "project/feed" + service-connection: feed-conn # optional (see Authentication) + registry: # mirrors images #4 + name: myacr.azurecr.io # ACR login server + service-connection: acr-conn # required when registry is set + service-connection: shared-conn # optional shared fallback for both targets +``` + +| Field | Type | Required | Purpose | +|-------|------|----------|---------| +| `feed` | scalar **or** `{ name, service-connection }` | optional | Enables the binary mirror (#1–#3). A bare string is shorthand for `{ name: }`. | +| `registry` | scalar **or** `{ name, service-connection }` | optional | Enables the image mirror (#4). | +| `service-connection` | string | optional | Shared fallback connection used by whichever target does not declare its own. | + +`feed` and `registry` are **independent** — set either, both, or neither. + +### Scalar shorthand + +A bare scalar is sugar for an object with no per-target connection: + +```yaml +supply-chain: + feed: my-feed # same as { name: my-feed } + registry: myacr.azurecr.io + service-connection: shared-conn +``` + +## Authentication + +Authentication is **asymmetric** because the two targets authenticate +differently: + +### Feed (binaries) + +The feed mirror uses `NuGetAuthenticate@1` + `DownloadPackage@1`. The effective +service connection resolves as: + +1. the feed's own `service-connection`, else +2. the top-level `service-connection`, else +3. `$(System.AccessToken)` (the build service identity). + +For a **same-organization** feed, no service connection is required: grant the +pipeline's build identity (e.g. ` Build Service`) the **Feed Reader** +role and `NuGetAuthenticate@1` authenticates automatically via +`$(System.AccessToken)`. Set a `service-connection` only for cross-org or +external feeds. + +### Registry (images) + +The image mirror authenticates with `az acr login` (`AzureCLI@2`) using the +resolved service connection, then `docker pull`s the rewritten image +references. **`$(System.AccessToken)` cannot authenticate to a container +registry**, so a service connection **must** resolve when `registry` is set — +either `registry.service-connection` or the top-level `service-connection`. +Compilation fails otherwise: + +``` +supply-chain.registry requires a service connection: set +`registry.service-connection` or a top-level `supply-chain.service-connection`. +A container registry (ACR) cannot be accessed with $(System.AccessToken). +``` + +The registry connection is an ARM / Azure service connection (the same kind +used by `permissions:`), passed to `AzureCLI@2` as `azureSubscription`. + +## What the feed and registry must contain + +Versions stay **pinned by the generating compiler** — the internal mirror must +host the exact pinned versions. A mirror that is a few days behind is fine: use +the matching `ado-aw` compiler version. + +### Feed packages (NuGet) + +The feed must host these NuGet packages (same base names, **bare semver** +versions — no leading `v`): + +| Package id | Version | Must contain | +|------------|---------|--------------| +| `ado-aw` | compiler version (e.g. `0.37.0`) | `ado-aw-linux-x64`, `checksums.txt` | +| `awf` | the AWF version (e.g. `0.27.3`) | `awf-linux-x64`, `checksums.txt` | +| `ado-script` | compiler version | `ado-script.zip`, `checksums.txt` | + +A NuGet package is a renamed zip; the compiler unzips it and relocates the +payload, so the package simply needs to carry the same files (and matching +`checksums.txt`) that the GitHub release ships. **Checksum verification with +`sha256sum -c checksums.txt` is preserved**, so the mirror must ship the +matching `checksums.txt`. + +### Registry images + +The registry must host the AWF and MCPG images under the **same repository +paths** (GHCR path minus the `ghcr.io/` host prefix), at the **same tags**: + +| Internal reference | +|--------------------| +| `/github/gh-aw-firewall/squid:` | +| `/github/gh-aw-firewall/agent:` | +| `/github/gh-aw-mcpg:v` | + +## Examples + +Mirror everything, two different connections: + +```yaml +supply-chain: + feed: + name: my-project/my-internal-feed + service-connection: feed-conn + registry: + name: myacr.azurecr.io + service-connection: acr-conn +``` + +Binaries only, same-org feed (uses `$(System.AccessToken)`): + +```yaml +supply-chain: + feed: my-internal-feed +``` + +Images only: + +```yaml +supply-chain: + registry: + name: myacr.azurecr.io + service-connection: acr-conn +``` + +## Network isolation note + +The mirror fetches (`NuGetAuthenticate@1`, `DownloadPackage@1`, `docker pull`, +`az acr login`) run as ordinary ADO steps on the build agent — **outside** the +AWF network-isolation sandbox, which wraps only the copilot agent command. +Consequently: + +- The feed/registry hosts are **not** added to the agent's AWF + `--allow-domains` allowlist (the network-isolated agent never contacts them). +- True isolation of the build agent from GitHub / GHCR is enforced by the agent + pool's own network policy; the `supply-chain:` rerouting is what lets such a + locked-down pool succeed. + +See also: [`docs/network.md`](network.md), +[`docs/front-matter.md`](front-matter.md). diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 926531a8..808d07d9 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -61,6 +61,7 @@ export default defineConfig({ { label: 'Runtimes', slug: 'reference/runtimes' }, { label: 'Safe Outputs', slug: 'reference/safe-outputs' }, { label: 'ado-aw-debug', slug: 'reference/ado-aw-debug' }, + { label: 'Supply Chain', slug: 'reference/supply-chain' }, { label: 'Targets', slug: 'reference/targets' }, { label: 'Network', slug: 'reference/network' }, { label: 'MCP', slug: 'reference/mcp' }, diff --git a/site/src/content/docs/reference/supply-chain.mdx b/site/src/content/docs/reference/supply-chain.mdx new file mode 100644 index 00000000..dba9fe44 --- /dev/null +++ b/site/src/content/docs/reference/supply-chain.mdx @@ -0,0 +1,105 @@ +--- +title: "Supply Chain reference" +description: "Mirror the compiler, AWF binary, ado-script bundle, and container images from an internal Azure DevOps Artifacts feed and registry." +--- + +By default a compiled agentic pipeline fetches four kinds of artifact from +GitHub / GHCR at run time: + +| # | Artifact | Default source | +| --- | --- | --- | +| 1 | `ado-aw` compiler (`ado-aw-linux-x64`) | `github.com/githubnext/ado-aw` releases | +| 2 | AWF firewall (`awf-linux-x64`) | `github.com/github/gh-aw-firewall` releases | +| 3 | `ado-script.zip` bundle | `github.com/githubnext/ado-aw` releases | +| 4 | AWF + MCPG container images | `ghcr.io/github/...` | + +The optional `supply-chain:` front-matter section reroutes these fetches to an +**internal Azure DevOps Artifacts feed** (binaries #1–#3) and/or an **internal +container registry** (images #4). It is intended for supply-chain-hardened +environments where the build agent pool cannot reach GitHub / GHCR. + +When `supply-chain:` is omitted, the generated pipeline is byte-for-byte +identical to before. + +## Configuration + +```yaml +supply-chain: + feed: # mirrors binaries #1, #2, #3 + name: my-project/my-feed # feed name or "project/feed" + service-connection: feed-conn # optional (see Authentication) + registry: # mirrors images #4 + name: myacr.azurecr.io # ACR login server + service-connection: acr-conn # required when registry is set + service-connection: shared-conn # optional shared fallback for both targets +``` + +| Field | Type | Required | Purpose | +| --- | --- | --- | --- | +| `feed` | scalar **or** `{ name, service-connection }` | optional | Enables the binary mirror. A bare string is shorthand for `{ name: }`. | +| `registry` | scalar **or** `{ name, service-connection }` | optional | Enables the image mirror. | +| `service-connection` | string | optional | Shared fallback connection for whichever target omits its own. | + +`feed` and `registry` are **independent** — set either, both, or neither. + +## Authentication + +Authentication is **asymmetric**. + +### Feed (binaries) + +Uses `NuGetAuthenticate@1` + `DownloadPackage@1`. The effective connection +resolves as: the feed's own `service-connection` → the top-level +`service-connection` → `$(System.AccessToken)`. + +For a **same-organization** feed no service connection is required: grant the +pipeline's build identity the **Feed Reader** role and `NuGetAuthenticate@1` +authenticates automatically via `$(System.AccessToken)`. Set a +`service-connection` only for cross-org or external feeds. + +### Registry (images) + +Uses `az acr login` (`AzureCLI@2`) then `docker pull`. +`$(System.AccessToken)` **cannot** authenticate to a container registry, so a +service connection **must** resolve when `registry` is set — otherwise +compilation fails: + +``` +supply-chain.registry requires a service connection: set +`registry.service-connection` or a top-level `supply-chain.service-connection`. +A container registry (ACR) cannot be accessed with $(System.AccessToken). +``` + +## What the mirror must contain + +Versions stay **pinned by the generating compiler**; the mirror must host the +exact pinned versions. Use the matching `ado-aw` compiler version against a +mirror that is a few days behind. + +NuGet packages (same base names, **bare semver** versions, no leading `v`): + +| Package id | Version | Must contain | +| --- | --- | --- | +| `ado-aw` | compiler version | `ado-aw-linux-x64`, `checksums.txt` | +| `awf` | AWF version | `awf-linux-x64`, `checksums.txt` | +| `ado-script` | compiler version | `ado-script.zip`, `checksums.txt` | + +Container images (same repository paths and tags, registry host swapped): + +| Internal reference | +| --- | +| `/github/gh-aw-firewall/squid:` | +| `/github/gh-aw-firewall/agent:` | +| `/github/gh-aw-mcpg:v` | + +`sha256sum -c checksums.txt` verification is preserved on the internal branch, +so the mirror must ship the matching `checksums.txt`. + +## Network isolation note + +The mirror fetches run as ordinary ADO steps on the build agent — **outside** +the AWF network-isolation sandbox, which wraps only the copilot agent command. +The feed/registry hosts are therefore **not** added to the agent's AWF +allowlist. Isolating the build agent from GitHub / GHCR is enforced by the +agent pool's own network policy; the `supply-chain:` rerouting is what lets such +a locked-down pool succeed. diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index e75497f9..c3d1adec 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -62,14 +62,14 @@ use super::ir::ids::{JobId, StepId}; use super::ir::job::{Job, Pool}; use super::ir::output::{OutputDecl, OutputRef}; use super::ir::step::{ - BashStep, CheckoutRepo, CheckoutStep, DownloadStep, PublishStep, Step, SubmodulesOpt, + BashStep, CheckoutRepo, CheckoutStep, DownloadStep, PublishStep, Step, SubmodulesOpt, TaskStep, }; use super::ir::tasks::docker_installer_step; use super::ir::{ CiTrigger, Parameter, ParameterDefault, ParameterKind, PipelineResource, PipelineVar, PrTrigger, RepositoryResource, Resources, Schedule, Triggers, }; -use super::types::{FrontMatter, OnConfig, PrMode, Repository as RepoCfg}; +use super::types::{FrontMatter, OnConfig, PrMode, Repository as RepoCfg, SupplyChainConfig}; /// Built pipeline context — the result of running every validation, /// scalar computation, extension declaration fanout, and canonical- @@ -115,6 +115,9 @@ pub(crate) fn build_pipeline_context( common::validate_update_pr_votes(front_matter)?; common::validate_resolve_pr_thread_statuses(front_matter)?; common::validate_ado_aw_debug_config(front_matter)?; + if let Some(sc) = front_matter.supply_chain() { + sc.validate()?; + } let mut extension_declarations = Vec::with_capacity(extensions.len()); for ext in extensions { @@ -739,7 +742,10 @@ fn build_agent_job( push_raw_yaml_if_nonempty(&mut steps, &cfg.engine_install_steps_yaml)?; // 5. Download agentic pipeline compiler - steps.push(Step::Bash(download_compiler_step(&cfg.compiler_version))); + steps.extend(download_compiler_step( + &cfg.compiler_version, + front_matter.supply_chain(), + )); // 6. Integrity check (when not skipped) push_raw_yaml_if_nonempty( @@ -766,10 +772,10 @@ fn build_agent_job( steps.push(Step::Task(docker_installer_step("26.1.4"))); // 11. Download AWF - steps.push(Step::Bash(download_awf_step())); + steps.extend(download_awf_step(front_matter.supply_chain())); // 12. Pre-pull AWF + MCPG container images - steps.push(Step::Bash(prepull_images_step(true))); + steps.extend(prepull_images_step(true, front_matter.supply_chain())); // 13. Extension prepare steps (typed) + user steps (RawYaml) steps.extend(ext_agent_prepare.iter().cloned()); @@ -791,6 +797,7 @@ fn build_agent_job( &cfg.mcpg_docker_env, &cfg.mcpg_step_env, cfg.debug_pipeline, + front_matter.supply_chain(), )?)); // 17. Verify MCP backends (debug-only) @@ -938,13 +945,16 @@ fn build_detection_job( // Engine install push_raw_yaml_if_nonempty(&mut steps, &cfg.engine_install_steps_yaml)?; // Download compiler - steps.push(Step::Bash(download_compiler_step(&cfg.compiler_version))); + steps.extend(download_compiler_step( + &cfg.compiler_version, + front_matter.supply_chain(), + )); // DockerInstaller steps.push(Step::Task(docker_installer_step("26.1.4"))); // Download AWF - steps.push(Step::Bash(download_awf_step())); + steps.extend(download_awf_step(front_matter.supply_chain())); // Pre-pull AWF (no MCPG image for detection) - steps.push(Step::Bash(prepull_images_step(false))); + steps.extend(prepull_images_step(false, front_matter.supply_chain())); // Prepare safe outputs for analysis steps.push(Step::Bash(prepare_safe_outputs_for_analysis( &cfg.working_directory, @@ -992,7 +1002,7 @@ fn build_detection_job( } fn build_safeoutputs_job( - _front_matter: &FrontMatter, + front_matter: &FrontMatter, cfg: &StandaloneCtx, prefix: &JobPrefix<'_>, ) -> Result { @@ -1007,7 +1017,10 @@ fn build_safeoutputs_job( condition: None, })); // Download compiler - steps.push(Step::Bash(download_compiler_step(&cfg.compiler_version))); + steps.extend(download_compiler_step( + &cfg.compiler_version, + front_matter.supply_chain(), + )); // Add compiler to path steps.push(Step::Bash(bash( "Add agentic compiler to path", @@ -1121,7 +1134,135 @@ fn checkout_self_step() -> Step { }) } -fn download_compiler_step(compiler_version: &str) -> BashStep { +/// Rewrite a GHCR image reference onto an internal registry when one is +/// configured. `base` is the GHCR path (e.g. +/// `ghcr.io/github/gh-aw-firewall/squid`), `tag` the image tag. When +/// `registry` is `None` the GHCR reference is returned unchanged. +/// +/// Centralised so the pre-pull step and the `docker run` invocation in +/// `start_mcpg_step` cannot drift on the rewritten reference. +fn image_ref(base: &str, tag: &str, registry: Option<&str>) -> String { + match registry { + Some(reg) => { + let path = base.strip_prefix("ghcr.io/").unwrap_or(base); + format!("{reg}/{path}:{tag}") + } + None => format!("{base}:{tag}"), + } +} + +/// Derive the ACR registry name (used by `az acr login --name`) from a login +/// server. Strips a trailing `.azurecr.io` when present; otherwise returns the +/// portion before the first `.` (falling back to the whole value). +fn acr_registry_name(login_server: &str) -> &str { + login_server + .strip_suffix(".azurecr.io") + .or_else(|| login_server.split('.').next()) + .unwrap_or(login_server) +} + +/// `AzureCLI@2` step that runs `az acr login` against an internal registry so +/// subsequent `docker pull` calls in the same job are authenticated. Uses the +/// resolved registry service connection (an ARM/Azure service connection). +fn acr_login_step(login_server: &str, connection: &str) -> TaskStep { + let name = acr_registry_name(login_server); + TaskStep::new("AzureCLI@2", "Authenticate to internal container registry") + .with_input("azureSubscription", connection) + .with_input("scriptType", "bash") + .with_input("scriptLocation", "inlineScript") + .with_input("inlineScript", format!("az acr login --name {name}\n")) +} + +/// `NuGetAuthenticate@1` step. When a service connection is resolved it is +/// passed via `nuGetServiceConnections` (cross-org/external feeds); otherwise +/// the task authenticates the build identity with `$(System.AccessToken)`. +pub(crate) fn nuget_authenticate_step(connection: Option<&str>) -> TaskStep { + let mut step = TaskStep::new("NuGetAuthenticate@1", "Authenticate to internal feed"); + if let Some(conn) = connection { + step = step.with_input("nuGetServiceConnections", conn); + } + step +} + +/// `DownloadPackage@1` step pulling a single NuGet package by name+version +/// from the internal feed into `download_path`. +pub(crate) fn download_package_step( + display: impl Into, + feed: &str, + package: &str, + version: &str, + download_path: &str, +) -> TaskStep { + TaskStep::new("DownloadPackage@1", display) + .with_input("packageType", "nuget") + .with_input("feed", feed) + .with_input("definition", package) + .with_input("version", version) + .with_input("downloadPath", download_path) +} + +/// Bash body that locates a payload file inside a `DownloadPackage@1` staging +/// directory — handling both the extracted-tree and raw-`.nupkg` delivery +/// shapes — copies it (plus `checksums.txt`) into `dest_dir`, then runs the +/// caller-supplied verify/relocate tail. `payload` is the artifact file name +/// (e.g. `ado-aw-linux-x64`); `tail` is appended after the files are staged in +/// `dest_dir` (the working directory is `dest_dir`). +fn extract_package_payload_bash(staging: &str, dest_dir: &str, payload: &str, tail: &str) -> String { + format!( + "set -eo pipefail\n\ + STAGING=\"{staging}\"\n\ + DEST=\"{dest_dir}\"\n\ + mkdir -p \"$DEST\"\n\ + \n\ + # DownloadPackage@1 may deliver an extracted tree or a raw .nupkg;\n\ + # handle both by unzipping any .nupkg when the payload is absent.\n\ + if [ -z \"$(find \"$STAGING\" -name '{payload}' -print -quit)\" ]; then\n \ + NUPKG=\"$(find \"$STAGING\" -name '*.nupkg' -print -quit)\"\n \ + if [ -n \"$NUPKG\" ]; then\n \ + unzip -o \"$NUPKG\" -d \"$STAGING\" >/dev/null\n \ + fi\n\ + fi\n\ + \n\ + BIN=\"$(find \"$STAGING\" -name '{payload}' -print -quit)\"\n\ + CHK=\"$(find \"$STAGING\" -name 'checksums.txt' -print -quit)\"\n\ + if [ -z \"$BIN\" ] || [ -z \"$CHK\" ]; then\n \ + echo \"##vso[task.complete result=Failed]{payload} or checksums.txt not found in package\"\n \ + exit 1\n\ + fi\n\ + cp \"$BIN\" \"$DEST/{payload}\"\n\ + cp \"$CHK\" \"$DEST/checksums.txt\"\n\ + \n\ + echo \"Verifying checksum...\"\n\ + cd \"$DEST\" || exit 1\n\ + grep \"{payload}\" checksums.txt | sha256sum -c -\n\ + {tail}" + ) +} + +fn download_compiler_step(compiler_version: &str, supply_chain: Option<&SupplyChainConfig>) -> Vec { + if let Some(feed) = supply_chain.and_then(|sc| sc.feed.as_ref()) { + let dest = "$(Pipeline.Workspace)/agentic-pipeline-compiler"; + let staging = "$(Pipeline.Workspace)/agentic-pipeline-compiler/_pkg"; + let connection = supply_chain.and_then(|sc| sc.feed_connection()); + let tail = "mv ado-aw-linux-x64 ado-aw\n\ + chmod +x ado-aw\n"; + let body = extract_package_payload_bash(staging, dest, "ado-aw-linux-x64", tail); + return vec![ + Step::Task(nuget_authenticate_step(connection)), + Step::Task(download_package_step( + format!("Download agentic pipeline compiler (v{compiler_version})"), + feed.name.as_str(), + "ado-aw", + compiler_version, + staging, + )), + Step::Bash(bash( + format!("Stage agentic pipeline compiler (v{compiler_version})"), + body, + )), + ]; + } + let script = format!( "set -eo pipefail\n\ COMPILER_VERSION=\"{compiler_version}\"\n\ @@ -1140,10 +1281,10 @@ fn download_compiler_step(compiler_version: &str) -> BashStep { mv ado-aw-linux-x64 ado-aw\n\ chmod +x ado-aw\n" ); - bash( + vec![Step::Bash(bash( format!("Download agentic pipeline compiler (v{compiler_version})"), script, - ) + ))] } fn substitute_integrity_check(yaml: &str, pipeline_path: &str, trigger_repo_dir: &str) -> String { @@ -1236,7 +1377,32 @@ fn prepare_agent_prompt_step(agent_content: &str) -> Result { Ok(bash("Prepare agent prompt", script)) } -fn download_awf_step() -> BashStep { +fn download_awf_step(supply_chain: Option<&SupplyChainConfig>) -> Vec { + if let Some(feed) = supply_chain.and_then(|sc| sc.feed.as_ref()) { + let dest = "$(Pipeline.Workspace)/awf"; + let staging = "$(Pipeline.Workspace)/awf/_pkg"; + let connection = supply_chain.and_then(|sc| sc.feed_connection()); + let tail = "mv awf-linux-x64 awf\n\ + chmod +x awf\n\ + echo \"##vso[task.prependpath]$(Pipeline.Workspace)/awf\"\n\ + ./awf --version\n"; + let body = extract_package_payload_bash(staging, dest, "awf-linux-x64", tail); + return vec![ + Step::Task(nuget_authenticate_step(connection)), + Step::Task(download_package_step( + format!("Download AWF (Agentic Workflow Firewall) v{AWF_VERSION}"), + feed.name.as_str(), + "awf", + AWF_VERSION, + staging, + )), + Step::Bash(bash( + format!("Stage AWF (Agentic Workflow Firewall) v{AWF_VERSION}"), + body, + )), + ]; + } + let script = format!( "set -eo pipefail\n\ \n\ @@ -1258,33 +1424,49 @@ fn download_awf_step() -> BashStep { echo \"##vso[task.prependpath]$(Pipeline.Workspace)/awf\"\n\ ./awf --version\n" ); - bash( + vec![Step::Bash(bash( format!("Download AWF (Agentic Workflow Firewall) v{AWF_VERSION}"), script, - ) + ))] } -fn prepull_images_step(include_mcpg: bool) -> BashStep { +fn prepull_images_step(include_mcpg: bool, supply_chain: Option<&SupplyChainConfig>) -> Vec { + let registry = supply_chain.and_then(|sc| sc.registry.as_ref()); + let registry_host = registry.map(|r| r.name.as_str()); + + let squid = image_ref("ghcr.io/github/gh-aw-firewall/squid", AWF_VERSION, registry_host); + let agent = image_ref("ghcr.io/github/gh-aw-firewall/agent", AWF_VERSION, registry_host); + let squid_latest = image_ref("ghcr.io/github/gh-aw-firewall/squid", "latest", registry_host); + let agent_latest = image_ref("ghcr.io/github/gh-aw-firewall/agent", "latest", registry_host); + let mut script = format!( "set -eo pipefail\n\ \n\ - docker pull ghcr.io/github/gh-aw-firewall/squid:{AWF_VERSION}\n\ - docker pull ghcr.io/github/gh-aw-firewall/agent:{AWF_VERSION}\n\ - docker tag ghcr.io/github/gh-aw-firewall/squid:{AWF_VERSION} ghcr.io/github/gh-aw-firewall/squid:latest\n\ - docker tag ghcr.io/github/gh-aw-firewall/agent:{AWF_VERSION} ghcr.io/github/gh-aw-firewall/agent:latest\n" + docker pull {squid}\n\ + docker pull {agent}\n\ + docker tag {squid} {squid_latest}\n\ + docker tag {agent} {agent_latest}\n" ); - if include_mcpg { - script.push_str(&format!("docker pull {MCPG_IMAGE}:v{MCPG_VERSION}\n")); - bash( - format!("Pre-pull AWF and MCPG container images (v{AWF_VERSION})"), - script, - ) + let display = if include_mcpg { + let mcpg = image_ref(MCPG_IMAGE, &format!("v{MCPG_VERSION}"), registry_host); + script.push_str(&format!("docker pull {mcpg}\n")); + format!("Pre-pull AWF and MCPG container images (v{AWF_VERSION})") } else { - bash( - format!("Pre-pull AWF container images (v{AWF_VERSION})"), - script, - ) + format!("Pre-pull AWF container images (v{AWF_VERSION})") + }; + + let mut steps = Vec::new(); + // When using an internal registry, authenticate before pulling so the + // job's docker daemon (shared with the subsequent `docker run` of MCPG) + // can reach the registry. + if let (Some(host), Some(conn)) = ( + registry_host, + supply_chain.and_then(|sc| sc.registry_connection()), + ) { + steps.push(Step::Task(acr_login_step(host, conn))); } + steps.push(Step::Bash(bash(display, script))); + steps } fn start_safeoutputs_server_step(enabled_tools_args: &str, working_directory: &str) -> BashStep { @@ -1334,8 +1516,12 @@ fn start_mcpg_step( mcpg_docker_env: &str, mcpg_step_env: &str, debug_pipeline: bool, + supply_chain: Option<&SupplyChainConfig>, ) -> Result { - let mcpg_image_v = format!("{MCPG_IMAGE}:v{MCPG_VERSION}"); + let registry_host = supply_chain + .and_then(|sc| sc.registry.as_ref()) + .map(|r| r.name.as_str()); + let mcpg_image_v = image_ref(MCPG_IMAGE, &format!("v{MCPG_VERSION}"), registry_host); // Build the docker-env block as additional `-e VAR=...` lines, one per // line, joined with `\n ` (newline + 2-space continuation indent to // match the surrounding `-e MCP_GATEWAY_*` lines). When no extensions diff --git a/src/compile/extensions/ado_script.rs b/src/compile/extensions/ado_script.rs index 298c6a04..5572f200 100644 --- a/src/compile/extensions/ado_script.rs +++ b/src/compile/extensions/ado_script.rs @@ -20,6 +20,7 @@ use anyhow::Result; use super::{CompileContext, CompilerExtension, Declarations, ExtensionPhase}; +use crate::compile::agentic_pipeline::{download_package_step, nuget_authenticate_step}; use crate::compile::filter_ir::{ GateContext, Severity, build_gate_step_typed, lower_pipeline_filters, lower_pr_filters, validate_pipeline_filters, validate_pr_filters, @@ -29,7 +30,7 @@ use crate::compile::ir::env::EnvValue; use crate::compile::ir::ids::StepId; use crate::compile::ir::output::OutputDecl; use crate::compile::ir::step::{BashStep, Step, TaskStep}; -use crate::compile::types::{PipelineFilters, PrFilters}; +use crate::compile::types::{PipelineFilters, PrFilters, SupplyChainConfig}; const GATE_EVAL_PATH: &str = "/tmp/ado-aw-scripts/ado-script/gate.js"; pub(crate) const IMPORT_EVAL_PATH: &str = "/tmp/ado-aw-scripts/ado-script/import.js"; @@ -153,6 +154,10 @@ pub struct AdoScriptExtension { /// Cloned from the front-matter because the extension outlives the /// borrow of `FrontMatter` in `collect_extensions`. pub pr_trigger_for_synth: Option, + /// Internal supply-chain configuration. When the `feed` mirror is + /// configured, the `ado-script.zip` bundle is pulled from the internal + /// Azure DevOps Artifacts feed instead of GitHub Releases. + pub supply_chain: Option, } impl AdoScriptExtension { @@ -326,9 +331,12 @@ impl AdoScriptExtension { } /// Returns the two-step bundle as typed `Step`s: a -/// `Step::Task(UseNode@1)` plus a `Step::Bash` for the curl + sha256 -/// + unzip pipeline. -fn install_and_download_steps_typed() -> Vec { +/// `Step::Task(UseNode@1)` plus a `Step::Bash` for the curl, sha256, +/// and unzip pipeline. When an internal feed is configured the bundle is +/// pulled from the Azure DevOps Artifacts feed (NuGet) instead of GitHub +/// Releases; the `.nupkg` is unzipped and `ado-script.zip` relocated, then +/// verified and unpacked exactly as in the GitHub path. +fn install_and_download_steps_typed(supply_chain: Option<&SupplyChainConfig>) -> Vec { let version = env!("CARGO_PKG_VERSION"); let install = { let mut t = @@ -337,6 +345,58 @@ fn install_and_download_steps_typed() -> Vec { t.condition = Some(Condition::Succeeded); t }; + + if let Some(feed) = supply_chain.and_then(|sc| sc.feed.as_ref()) { + let connection = supply_chain.and_then(|sc| sc.feed_connection()); + let mut auth = nuget_authenticate_step(connection); + auth.condition = Some(Condition::Succeeded); + let download_pkg = { + let mut t = download_package_step( + format!("Download ado-aw scripts (v{version})"), + feed.name.as_str(), + "ado-script", + version, + "/tmp/ado-aw-scripts/_pkg", + ); + t.timeout = Some(std::time::Duration::from_secs(300)); + t.condition = Some(Condition::Succeeded); + t + }; + // Locate ado-script.zip + checksums.txt within the package staging + // dir (handling both extracted-tree and raw-.nupkg delivery), + // verify, then unzip the bundle into /tmp/ado-aw-scripts/. + let script = "\ + set -eo pipefail\n\ + mkdir -p /tmp/ado-aw-scripts\n\ + STAGING=/tmp/ado-aw-scripts/_pkg\n\ + if [ -z \"$(find \"$STAGING\" -name 'ado-script.zip' -print -quit)\" ]; then\n \ + NUPKG=\"$(find \"$STAGING\" -name '*.nupkg' -print -quit)\"\n \ + if [ -n \"$NUPKG\" ]; then\n \ + unzip -o \"$NUPKG\" -d \"$STAGING\" >/dev/null\n \ + fi\n\ + fi\n\ + ZIP=\"$(find \"$STAGING\" -name 'ado-script.zip' -print -quit)\"\n\ + CHK=\"$(find \"$STAGING\" -name 'checksums.txt' -print -quit)\"\n\ + if [ -z \"$ZIP\" ] || [ -z \"$CHK\" ]; then\n \ + echo \"##vso[task.complete result=Failed]ado-script.zip or checksums.txt not found in package\"\n \ + exit 1\n\ + fi\n\ + cp \"$ZIP\" /tmp/ado-aw-scripts/ado-script.zip\n\ + cp \"$CHK\" /tmp/ado-aw-scripts/checksums.txt\n\ + cd /tmp/ado-aw-scripts && grep \"ado-script.zip\" checksums.txt | sha256sum -c -\n\ + unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/\n" + .to_string(); + let mut b = BashStep::new(format!("Stage ado-aw scripts (v{version})"), script) + .with_condition(Condition::Succeeded); + b.timeout = Some(std::time::Duration::from_secs(300)); + return vec![ + Step::Task(install), + Step::Task(auth), + Step::Task(download_pkg), + Step::Bash(b), + ]; + } + let download = { let script = format!( "set -eo pipefail\n\ @@ -524,7 +584,7 @@ impl CompilerExtension for AdoScriptExtension { // ─── Setup job ───────────────────────────────────────── let mut setup_steps: Vec = Vec::new(); if !pr_checks.is_empty() || !pipeline_checks.is_empty() || self.synthetic_pr_active() { - setup_steps.extend(install_and_download_steps_typed()); + setup_steps.extend(install_and_download_steps_typed(self.supply_chain.as_ref())); if let Some(pr) = self.pr_trigger_for_synth.as_ref() { let spec_b64 = crate::compile::filter_ir::build_pr_synth_spec(pr)?; setup_steps.push(Step::Bash(synthetic_pr_step_typed(&spec_b64)?)); @@ -562,7 +622,7 @@ impl CompilerExtension for AdoScriptExtension { || self.exec_context_pr_checks_active || self.exec_context_repo_active { - agent_prepare_steps.extend(install_and_download_steps_typed()); + agent_prepare_steps.extend(install_and_download_steps_typed(self.supply_chain.as_ref())); if import_active { agent_prepare_steps.push(resolver_step_typed()); } @@ -765,6 +825,7 @@ mod tests { exec_context_pr_checks_active: false, exec_context_repo_active: false, pr_trigger_for_synth: None, + supply_chain: None, } } @@ -849,6 +910,7 @@ mod tests { filters: None, ..Default::default() }), + supply_chain: None, }; let fm: FrontMatter = serde_yaml::from_str("name: t\ndescription: t").unwrap(); let ctx = CompileContext::for_test(&fm); @@ -904,6 +966,7 @@ mod tests { filters: None, ..Default::default() }), + supply_chain: None, }; let fm: FrontMatter = serde_yaml::from_str("name: t\ndescription: t").unwrap(); let ctx = CompileContext::for_test(&fm); @@ -943,7 +1006,7 @@ mod tests { .starts_with(zip_prefix), "IMPORT_EVAL_PATH suffix must match zip internal path prefix used in release.yml" ); - let steps = install_and_download_steps_typed(); + let steps = install_and_download_steps_typed(None); match &steps[1] { Step::Bash(download) => assert!( download.script.contains("-d /tmp/ado-aw-scripts/"), @@ -1076,6 +1139,7 @@ mod tests { filters: None, ..Default::default() }), + supply_chain: None, } } @@ -1593,6 +1657,7 @@ mod tests { filters: None, ..Default::default() }), + supply_chain: None, }; let fm: FrontMatter = serde_yaml::from_str("name: t\ndescription: t").unwrap(); let ctx = CompileContext::for_test(&fm); diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 802d3d3f..eb84f326 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -695,6 +695,7 @@ pub fn collect_extensions(front_matter: &FrontMatter) -> Vec { // bearer / no REST, pure git). exec_context_repo_active: repo_contributor_will_activate(front_matter), pr_trigger_for_synth, + supply_chain: front_matter.supply_chain().cloned(), } })), // Always-on execution-context extension. Owns the `aw-context/` diff --git a/src/compile/types.rs b/src/compile/types.rs index 74bf7846..a4de3333 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -726,6 +726,12 @@ pub struct FrontMatter { /// PR context is on when `on.pr` is set. #[serde(default, rename = "execution-context")] pub execution_context: Option, + /// Internal supply-chain configuration — when present, mirrors the + /// GitHub/GHCR artifacts (compiler, AWF binary, ado-script bundle, AWF/MCPG + /// images) from an internal Azure DevOps Artifacts feed and/or container + /// registry. See `docs/supply-chain.md`. + #[serde(default, rename = "supply-chain")] + pub supply_chain: Option, } impl FrontMatter { @@ -764,6 +770,11 @@ impl FrontMatter { pub fn pipeline_filters(&self) -> Option<&PipelineFilters> { self.pipeline_trigger().and_then(|pt| pt.filters.as_ref()) } + + /// Get the internal supply-chain configuration (if any). + pub fn supply_chain(&self) -> Option<&SupplyChainConfig> { + self.supply_chain.as_ref() + } } impl SanitizeConfigTrait for FrontMatter { @@ -823,6 +834,9 @@ impl SanitizeConfigTrait for FrontMatter { if let Some(ref mut ec) = self.execution_context { ec.sanitize_config_fields(); } + if let Some(ref mut sc) = self.supply_chain { + sc.sanitize_config_fields(); + } } } @@ -915,6 +929,164 @@ impl SanitizeConfigTrait for AdoAwDebugConfig { } } +/// Internal supply-chain configuration. +/// +/// Lives under the optional `supply-chain:` top-level front-matter key. When +/// present, the compiler mirrors the artifacts it normally fetches from +/// GitHub Releases / GHCR from an internal Azure DevOps Artifacts feed and/or +/// an internal container registry instead. `feed` and `registry` are +/// independent — a user may set either, both, or neither. +/// +/// See `docs/supply-chain.md`. +#[derive(Debug, Deserialize, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct SupplyChainConfig { + /// Internal Azure DevOps Artifacts feed for the binary artifacts + /// (`ado-aw`, `awf`, `ado-script`). When omitted those binaries are + /// fetched from GitHub Releases as today. + #[serde(default)] + pub feed: Option, + /// Internal container registry (ACR login server) for the AWF and MCPG + /// images. When omitted images are pulled from GHCR as today. + #[serde(default)] + pub registry: Option, + /// Shared fallback service connection used by whichever target does not + /// declare its own `service-connection`. + #[serde(default, rename = "service-connection")] + pub service_connection: Option, +} + +/// A feed target. Accepts either a bare scalar (the feed reference) or an +/// object `{ name, service-connection }`. The scalar form is sugar for an +/// object with no per-target connection. +#[derive(Debug, Clone)] +pub struct FeedConfig { + /// Feed reference: a bare feed name or `project/feed`. + pub name: crate::secure::FeedRef, + /// Optional per-target service connection (overrides the top-level one). + pub service_connection: Option, +} + +/// A registry target. Accepts either a bare scalar (the ACR login server) or +/// an object `{ name, service-connection }`. +#[derive(Debug, Clone)] +pub struct RegistryConfig { + /// ACR login server, e.g. `myacr.azurecr.io`. + pub name: crate::secure::HostName, + /// Optional per-target service connection (overrides the top-level one). + pub service_connection: Option, +} + +impl<'de> Deserialize<'de> for FeedConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Obj { + name: crate::secure::FeedRef, + #[serde(default, rename = "service-connection")] + service_connection: Option, + } + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Scalar(crate::secure::FeedRef), + Obj(Obj), + } + Ok(match Repr::deserialize(deserializer)? { + Repr::Scalar(name) => FeedConfig { + name, + service_connection: None, + }, + Repr::Obj(o) => FeedConfig { + name: o.name, + service_connection: o.service_connection, + }, + }) + } +} + +impl<'de> Deserialize<'de> for RegistryConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Obj { + name: crate::secure::HostName, + #[serde(default, rename = "service-connection")] + service_connection: Option, + } + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Scalar(crate::secure::HostName), + Obj(Obj), + } + Ok(match Repr::deserialize(deserializer)? { + Repr::Scalar(name) => RegistryConfig { + name, + service_connection: None, + }, + Repr::Obj(o) => RegistryConfig { + name: o.name, + service_connection: o.service_connection, + }, + }) + } +} + +impl SupplyChainConfig { + /// Effective service connection for the feed (binary) mirror. + /// + /// Resolution: the feed's own `service-connection` → the top-level + /// `service-connection`. `None` means "authenticate with + /// `$(System.AccessToken)`" (valid for same-org feeds). + pub fn feed_connection(&self) -> Option<&str> { + self.feed + .as_ref() + .and_then(|f| f.service_connection.as_deref()) + .or(self.service_connection.as_deref()) + } + + /// Effective service connection for the registry (image) mirror. + /// + /// Resolution: the registry's own `service-connection` → the top-level + /// `service-connection`. `None` is invalid when `registry` is set (ACR has + /// no `System.AccessToken` path) — see [`SupplyChainConfig::validate`]. + pub fn registry_connection(&self) -> Option<&str> { + self.registry + .as_ref() + .and_then(|r| r.service_connection.as_deref()) + .or(self.service_connection.as_deref()) + } + + /// Validate cross-field rules. Errors when `registry` is configured but no + /// service connection resolves for it. + pub fn validate(&self) -> anyhow::Result<()> { + if self.registry.is_some() && self.registry_connection().is_none() { + anyhow::bail!( + "supply-chain.registry requires a service connection: set \ + `registry.service-connection` or a top-level \ + `supply-chain.service-connection`. A container registry (ACR) \ + cannot be accessed with $(System.AccessToken)." + ); + } + Ok(()) + } +} + +impl SanitizeConfigTrait for SupplyChainConfig { + fn sanitize_config_fields(&mut self) { + // All fields are validated newtypes (FeedRef / HostName / + // ServiceConnection) constrained at deserialization time; there is + // nothing further to sanitize. + } +} + /// Repository resource definition #[derive(Debug, Deserialize, Clone, SanitizeConfig)] pub struct Repository { @@ -1815,6 +1987,79 @@ impl SanitizeConfigTrait for LabelFilter { mod tests { use super::*; + // ─── SupplyChainConfig deserialization + resolution ────────────────────── + + fn parse_supply_chain(yaml: &str) -> SupplyChainConfig { + let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + serde_yaml::from_value(v["supply-chain"].clone()).unwrap() + } + + #[test] + fn test_supply_chain_scalar_feed_shorthand() { + let sc = parse_supply_chain("supply-chain:\n feed: my-feed"); + let feed = sc.feed.as_ref().unwrap(); + assert_eq!(feed.name.as_str(), "my-feed"); + assert!(feed.service_connection.is_none()); + // No connection resolves → System.AccessToken (None). + assert_eq!(sc.feed_connection(), None); + } + + #[test] + fn test_supply_chain_object_feed_with_connection() { + let sc = parse_supply_chain( + "supply-chain:\n feed:\n name: proj/my-feed\n service-connection: feed-conn", + ); + let feed = sc.feed.as_ref().unwrap(); + assert_eq!(feed.name.as_str(), "proj/my-feed"); + assert_eq!(sc.feed_connection(), Some("feed-conn")); + } + + #[test] + fn test_supply_chain_top_level_connection_is_fallback() { + let sc = parse_supply_chain( + "supply-chain:\n feed: my-feed\n registry: myacr.azurecr.io\n service-connection: shared", + ); + assert_eq!(sc.feed_connection(), Some("shared")); + assert_eq!(sc.registry_connection(), Some("shared")); + } + + #[test] + fn test_supply_chain_per_target_overrides_top_level() { + let sc = parse_supply_chain( + "supply-chain:\n \ + feed:\n name: my-feed\n service-connection: feed-conn\n \ + registry:\n name: myacr.azurecr.io\n service-connection: acr-conn\n \ + service-connection: shared", + ); + assert_eq!(sc.feed_connection(), Some("feed-conn")); + assert_eq!(sc.registry_connection(), Some("acr-conn")); + } + + #[test] + fn test_supply_chain_validate_registry_requires_connection() { + let sc = parse_supply_chain("supply-chain:\n registry: myacr.azurecr.io"); + assert!(sc.validate().is_err()); + + let ok = parse_supply_chain( + "supply-chain:\n registry:\n name: myacr.azurecr.io\n service-connection: acr-conn", + ); + assert!(ok.validate().is_ok()); + } + + #[test] + fn test_supply_chain_feed_only_validates() { + let sc = parse_supply_chain("supply-chain:\n feed: my-feed"); + assert!(sc.validate().is_ok()); + } + + #[test] + fn test_supply_chain_rejects_unknown_fields() { + let v: serde_yaml::Value = + serde_yaml::from_str("supply-chain:\n feed: my-feed\n bogus: x").unwrap(); + let res: Result = serde_yaml::from_value(v["supply-chain"].clone()); + assert!(res.is_err(), "deny_unknown_fields must reject unknown keys"); + } + // ─── PoolConfig deserialization ────────────────────────────────────────── #[test] diff --git a/src/secure.rs b/src/secure.rs index 06687d01..b12cdb49 100644 --- a/src/secure.rs +++ b/src/secure.rs @@ -279,6 +279,34 @@ validated_string! { } } +validated_string! { + /// An Azure DevOps Artifacts feed reference (`feed` or `project/feed`). + FeedRef, "feed", |value: &str, label: &str| { + if validate::is_valid_feed_ref(value) { + Ok(()) + } else { + anyhow::bail!( + "{label} '{value}' must be a feed name or 'project/feed' \ + containing only [A-Za-z0-9._/-] (no '..', no leading/trailing '/')" + ) + } + } +} + +validated_string! { + /// An Azure DevOps service-connection name or GUID. + ServiceConnection, "service-connection", |value: &str, label: &str| { + if validate::is_valid_service_connection(value) { + Ok(()) + } else { + anyhow::bail!( + "{label} '{value}' must be non-empty, at most 256 characters, \ + and free of quotes and control characters" + ) + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/validate.rs b/src/validate.rs index 5a577270..0f745e22 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -85,6 +85,39 @@ pub fn is_valid_artifact_name(s: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) } +/// Validate an Azure DevOps Artifacts feed reference. +/// +/// Permits a bare feed name or a single `project/feed` form. Allowlist is +/// `[A-Za-z0-9._/-]`; rejects empty strings, `..` traversal, leading/trailing +/// `/`, and more than one `/` separator. The strict charset blocks shell +/// metacharacters in case the value is interpolated into a generated step. +pub fn is_valid_feed_ref(s: &str) -> bool { + !s.is_empty() + && !s.contains("..") + && !s.starts_with('/') + && !s.ends_with('/') + && s.matches('/').count() <= 1 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/')) +} + +/// Validate an Azure DevOps service-connection name or GUID. +/// +/// Connection display names legitimately contain spaces and assorted +/// punctuation, so this is a deny-list rather than an allowlist: it rejects +/// empty strings, anything over 256 characters, control characters +/// (including newlines), and quote characters. Values flow into YAML task +/// inputs (`azureSubscription`, `nuGetServiceConnections`, +/// `containerRegistry`) where these characters could break out of the scalar. +pub fn is_valid_service_connection(s: &str) -> bool { + !s.is_empty() + && s.len() <= 256 + && !s.contains('\'') + && !s.contains('"') + && !s.contains('`') + && !s.chars().any(|c| c.is_control()) +} + /// Characters allowed in individual engine.args entries. /// Strict allowlist to prevent shell injection inside AWF single-quoted commands. pub fn is_valid_arg(s: &str) -> bool { @@ -621,6 +654,33 @@ mod tests { // ── Character allowlist validators ────────────────────────────────── + #[test] + fn test_is_valid_feed_ref() { + assert!(is_valid_feed_ref("my-feed")); + assert!(is_valid_feed_ref("project/feed")); + assert!(is_valid_feed_ref("my.feed_v2")); + assert!(!is_valid_feed_ref("")); + assert!(!is_valid_feed_ref("a/b/c")); // more than one separator + assert!(!is_valid_feed_ref("/leading")); + assert!(!is_valid_feed_ref("trailing/")); + assert!(!is_valid_feed_ref("../escape")); + assert!(!is_valid_feed_ref("has space")); + assert!(!is_valid_feed_ref("inject;rm")); + } + + #[test] + fn test_is_valid_service_connection() { + assert!(is_valid_service_connection("acr-conn")); + assert!(is_valid_service_connection("My ACR Connection")); // spaces allowed + assert!(is_valid_service_connection("11112222-3333-4444-5555-666677778888")); + assert!(!is_valid_service_connection("")); + assert!(!is_valid_service_connection("with\nnewline")); + assert!(!is_valid_service_connection("with'quote")); + assert!(!is_valid_service_connection("with\"quote")); + assert!(!is_valid_service_connection("with`tick")); + assert!(!is_valid_service_connection(&"x".repeat(257))); + } + #[test] fn test_is_safe_path_segment() { assert!(is_safe_path_segment("my-repo")); diff --git a/tests/bash_lint_tests.rs b/tests/bash_lint_tests.rs index 88f10d18..d7ab2d7a 100644 --- a/tests/bash_lint_tests.rs +++ b/tests/bash_lint_tests.rs @@ -81,6 +81,7 @@ const FIXTURES: &[&str] = &[ "job-agent.md", "stage-agent.md", "execution-context-agent.md", + "supply-chain-agent.md", ]; /// Step display names that the lint expects to find at least once across all diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 6e338cb6..56544739 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -6009,3 +6009,205 @@ fn test_pr_mode_policy_omits_synth_and_emits_trigger_none() { "mode: policy must not emit a narrowed CI trigger" ); } + +// ─── Internal supply-chain (feed + registry mirror) tests ─────────────────── + +/// Compile a small inline agent body and return (success, stdout, stderr). +fn compile_inline_source(name: &str, source: &str) -> (bool, String, String) { + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-supply-chain-{name}-{}", + std::process::id() + )); + fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); + let input_path = temp_dir.join(format!("{name}.md")); + let output_path = temp_dir.join(format!("{name}.yml")); + fs::write(&input_path, source).unwrap(); + + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run compiler"); + let compiled = fs::read_to_string(&output_path).unwrap_or_default(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let _ = fs::remove_dir_all(&temp_dir); + (output.status.success(), compiled, stderr) +} + +/// With `supply-chain.feed` + `supply-chain.registry` configured, every +/// GitHub/GHCR fetch is rerouted to the internal feed + registry while +/// checksum verification is preserved. +#[test] +fn test_supply_chain_full_reroutes_all_artifacts() { + let compiled = compile_fixture("supply-chain-agent.md"); + assert_valid_yaml(&compiled, "supply-chain-agent.md"); + + // (a) No GitHub release-download URLs remain. + assert!( + !compiled.contains("github.com/githubnext/ado-aw/releases"), + "ado-aw/ado-script GitHub release URLs must be gone in feed mode" + ); + assert!( + !compiled.contains("github.com/github/gh-aw-firewall/releases"), + "AWF GitHub release URLs must be gone in feed mode" + ); + // (b) No GHCR image references remain. + assert!( + !compiled.contains("ghcr.io"), + "GHCR image references must be gone in registry mode" + ); + + // (c) Standard ADO tasks are present for the binary mirror. + assert!( + compiled.contains("- task: NuGetAuthenticate@1"), + "NuGetAuthenticate@1 must be emitted for the feed mirror" + ); + assert!( + compiled.contains("nuGetServiceConnections: feed-conn"), + "feed's resolved service connection must be passed to NuGetAuthenticate@1" + ); + for definition in ["definition: ado-aw", "definition: awf", "definition: ado-script"] { + assert!( + compiled.contains(definition), + "DownloadPackage@1 must pull {definition}" + ); + } + assert!( + compiled.contains("feed: my-project/my-internal-feed"), + "DownloadPackage@1 must target the configured feed" + ); + + // (d) Internal registry rewrite + ACR auth for images. + assert!( + compiled.contains("- task: AzureCLI@2") && compiled.contains("az acr login --name myacr"), + "ACR login must be emitted before docker pull in registry mode" + ); + assert!( + compiled.contains("docker pull myacr.azurecr.io/github/gh-aw-firewall/squid:"), + "AWF images must be pulled from the internal registry" + ); + assert!( + compiled.contains("myacr.azurecr.io/github/gh-aw-mcpg:"), + "MCPG image must be rewritten onto the internal registry (pull + docker run)" + ); + + // (e) Checksum verification is retained. + assert!( + compiled.contains("sha256sum -c"), + "checksum verification must be preserved on the internal branch" + ); +} + +/// Absent `supply-chain:` leaves the default GitHub/GHCR fetch path intact. +#[test] +fn test_supply_chain_absent_uses_github_and_ghcr() { + let compiled = compile_fixture("minimal-agent.md"); + assert!( + compiled.contains("github.com/githubnext/ado-aw/releases"), + "default path must fetch the compiler from GitHub Releases" + ); + assert!( + compiled.contains("ghcr.io/github/gh-aw-firewall/squid:"), + "default path must pull AWF images from GHCR" + ); + assert!( + !compiled.contains("DownloadPackage@1"), + "default path must not emit DownloadPackage@1" + ); + assert!( + !compiled.contains("az acr login"), + "default path must not emit ACR login" + ); +} + +/// `feed` only (scalar, same-org) mirrors binaries via `$(System.AccessToken)` +/// — no `nuGetServiceConnections` — and leaves images on GHCR. +#[test] +fn test_supply_chain_feed_only_keeps_ghcr_and_uses_system_token() { + let source = r#"--- +name: "Feed Only" +description: "feed scalar, same-org System.AccessToken" +supply-chain: + feed: my-internal-feed +--- + +## Body +"#; + let (ok, compiled, stderr) = compile_inline_source("feed-only", source); + assert!(ok, "feed-only config should compile: {stderr}"); + + assert!( + compiled.contains("- task: NuGetAuthenticate@1"), + "feed mirror must authenticate" + ); + assert!( + !compiled.contains("nuGetServiceConnections"), + "same-org feed with no connection must use System.AccessToken (no nuGetServiceConnections)" + ); + assert!( + compiled.contains("definition: ado-script"), + "ado-script bundle must come from the feed" + ); + // Registry not configured → images stay on GHCR, no ACR login. + assert!( + compiled.contains("ghcr.io/github/gh-aw-firewall/squid:"), + "images must stay on GHCR when registry is unset" + ); + assert!( + !compiled.contains("az acr login"), + "no ACR login when registry is unset" + ); +} + +/// `registry` without any resolvable service connection is rejected at compile +/// time (ACR has no `$(System.AccessToken)` path). +#[test] +fn test_supply_chain_registry_without_connection_fails() { + let source = r#"--- +name: "Bad Registry" +description: "registry without a connection" +supply-chain: + registry: myacr.azurecr.io +--- + +## Body +"#; + let (ok, _compiled, stderr) = compile_inline_source("bad-registry", source); + assert!(!ok, "registry without a connection must fail to compile"); + assert!( + stderr.contains("supply-chain.registry requires a service connection"), + "error must explain the missing registry connection: {stderr}" + ); +} + +/// A top-level `service-connection` is used as the fallback for both targets +/// when neither declares its own. +#[test] +fn test_supply_chain_top_level_connection_fallback() { + let source = r#"--- +name: "Shared Conn" +description: "shared fallback connection" +supply-chain: + feed: my-project/my-internal-feed + registry: myacr.azurecr.io + service-connection: shared-conn +--- + +## Body +"#; + let (ok, compiled, stderr) = compile_inline_source("shared-conn", source); + assert!(ok, "shared-connection config should compile: {stderr}"); + assert!( + compiled.contains("nuGetServiceConnections: shared-conn"), + "feed must fall back to the top-level connection" + ); + assert!( + compiled.contains("azureSubscription: shared-conn"), + "registry ACR login must fall back to the top-level connection" + ); +} diff --git a/tests/fixtures/supply-chain-agent.md b/tests/fixtures/supply-chain-agent.md new file mode 100644 index 00000000..25727416 --- /dev/null +++ b/tests/fixtures/supply-chain-agent.md @@ -0,0 +1,16 @@ +--- +name: "Supply Chain Agent" +description: "Exercises the internal supply-chain feed + registry mirror" +supply-chain: + feed: + name: my-project/my-internal-feed + service-connection: feed-conn + registry: + name: myacr.azurecr.io + service-connection: acr-conn +--- + +## Supply Chain Agent + +This agent mirrors its compiler, AWF binary, ado-script bundle, and container +images from an internal Azure DevOps Artifacts feed and container registry. From c33f671846616a3055bcbee701a6864a6a130794 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 17 Jun 2026 23:49:04 +0100 Subject: [PATCH 2/4] fix(compile): tag AWF images to GHCR :latest names AWF expects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Rust PR review feedback on the supply-chain mirror: - Bug: prepull_images_step tagged the AWF squid/agent :latest aliases onto the internal registry, but run_agent_step invokes AWF with --skip-pull and no image-name flags, so AWF resolves its built-in ghcr.io/.../{squid,agent}:latest names — absent from the local cache. Now the :latest aliases always use the GHCR names AWF expects, tagged from the internally pulled image. - Hoist a single NuGetAuthenticate@1 per job (feed_auth_step) instead of one per artifact, reducing redundant idempotent auth steps. - Add a SAFETY note to extract_package_payload_bash documenting that callers must pass only trusted compile-time-constant strings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/agentic_pipeline.rs | 48 ++++++++++++++++++++++++++++----- tests/compiler_tests.rs | 33 ++++++++++++++++++++--- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index c3d1adec..68312e74 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -742,6 +742,11 @@ fn build_agent_job( push_raw_yaml_if_nonempty(&mut steps, &cfg.engine_install_steps_yaml)?; // 5. Download agentic pipeline compiler + // Hoist one NuGetAuthenticate@1 for the whole job when the feed mirror + // is active, ahead of the compiler/AWF DownloadPackage@1 steps. + if let Some(auth) = feed_auth_step(front_matter.supply_chain()) { + steps.push(auth); + } steps.extend(download_compiler_step( &cfg.compiler_version, front_matter.supply_chain(), @@ -944,6 +949,10 @@ fn build_detection_job( // Engine install push_raw_yaml_if_nonempty(&mut steps, &cfg.engine_install_steps_yaml)?; + // One NuGetAuthenticate@1 for the whole Detection job (feed mirror). + if let Some(auth) = feed_auth_step(front_matter.supply_chain()) { + steps.push(auth); + } // Download compiler steps.extend(download_compiler_step( &cfg.compiler_version, @@ -1017,6 +1026,10 @@ fn build_safeoutputs_job( condition: None, })); // Download compiler + // One NuGetAuthenticate@1 for the whole SafeOutputs job (feed mirror). + if let Some(auth) = feed_auth_step(front_matter.supply_chain()) { + steps.push(auth); + } steps.extend(download_compiler_step( &cfg.compiler_version, front_matter.supply_chain(), @@ -1207,6 +1220,12 @@ pub(crate) fn download_package_step( /// caller-supplied verify/relocate tail. `payload` is the artifact file name /// (e.g. `ado-aw-linux-x64`); `tail` is appended after the files are staged in /// `dest_dir` (the working directory is `dest_dir`). +/// +/// SAFETY: every parameter is interpolated verbatim into a `format!()` shell +/// body with no escaping. All callers MUST pass compile-time-constant, +/// trusted strings only (today: hardcoded ADO macro paths and literal payload +/// names). Never pass user/front-matter-controlled data here — doing so would +/// introduce shell-command injection into the generated pipeline. fn extract_package_payload_bash(staging: &str, dest_dir: &str, payload: &str, tail: &str) -> String { format!( "set -eo pipefail\n\ @@ -1239,16 +1258,28 @@ fn extract_package_payload_bash(staging: &str, dest_dir: &str, payload: &str, ta ) } +/// `NuGetAuthenticate@1` step to emit **once per job** when the feed mirror is +/// active. Hoisting a single auth step (keyed on the resolved feed connection) +/// keeps the per-artifact `DownloadPackage@1` calls authenticated without +/// repeating the (idempotent) auth task for every binary. Returns `None` when +/// no feed is configured. +fn feed_auth_step(supply_chain: Option<&SupplyChainConfig>) -> Option { + let sc = supply_chain?; + sc.feed + .as_ref() + .map(|_| Step::Task(nuget_authenticate_step(sc.feed_connection()))) +} + fn download_compiler_step(compiler_version: &str, supply_chain: Option<&SupplyChainConfig>) -> Vec { if let Some(feed) = supply_chain.and_then(|sc| sc.feed.as_ref()) { let dest = "$(Pipeline.Workspace)/agentic-pipeline-compiler"; let staging = "$(Pipeline.Workspace)/agentic-pipeline-compiler/_pkg"; - let connection = supply_chain.and_then(|sc| sc.feed_connection()); let tail = "mv ado-aw-linux-x64 ado-aw\n\ chmod +x ado-aw\n"; let body = extract_package_payload_bash(staging, dest, "ado-aw-linux-x64", tail); + // Auth is hoisted to the job builder via `feed_auth_step` (one + // NuGetAuthenticate@1 per job, not per artifact). return vec![ - Step::Task(nuget_authenticate_step(connection)), Step::Task(download_package_step( format!("Download agentic pipeline compiler (v{compiler_version})"), feed.name.as_str(), @@ -1381,14 +1412,13 @@ fn download_awf_step(supply_chain: Option<&SupplyChainConfig>) -> Vec { if let Some(feed) = supply_chain.and_then(|sc| sc.feed.as_ref()) { let dest = "$(Pipeline.Workspace)/awf"; let staging = "$(Pipeline.Workspace)/awf/_pkg"; - let connection = supply_chain.and_then(|sc| sc.feed_connection()); let tail = "mv awf-linux-x64 awf\n\ chmod +x awf\n\ echo \"##vso[task.prependpath]$(Pipeline.Workspace)/awf\"\n\ ./awf --version\n"; let body = extract_package_payload_bash(staging, dest, "awf-linux-x64", tail); + // Auth is hoisted to the job builder via `feed_auth_step`. return vec![ - Step::Task(nuget_authenticate_step(connection)), Step::Task(download_package_step( format!("Download AWF (Agentic Workflow Firewall) v{AWF_VERSION}"), feed.name.as_str(), @@ -1436,8 +1466,14 @@ fn prepull_images_step(include_mcpg: bool, supply_chain: Option<&SupplyChainConf let squid = image_ref("ghcr.io/github/gh-aw-firewall/squid", AWF_VERSION, registry_host); let agent = image_ref("ghcr.io/github/gh-aw-firewall/agent", AWF_VERSION, registry_host); - let squid_latest = image_ref("ghcr.io/github/gh-aw-firewall/squid", "latest", registry_host); - let agent_latest = image_ref("ghcr.io/github/gh-aw-firewall/agent", "latest", registry_host); + // The local `:latest` aliases must ALWAYS carry the GHCR image names that + // AWF resolves by default when invoked with `--skip-pull` (run_agent_step + // passes no `--awf-*-image` flags). Tagging them onto the internal + // registry would leave AWF's expected `ghcr.io/.../{squid,agent}:latest` + // names absent from the local Docker cache, so the firewall containers + // would fail to start. Hence `None` here regardless of pull source. + let squid_latest = image_ref("ghcr.io/github/gh-aw-firewall/squid", "latest", None); + let agent_latest = image_ref("ghcr.io/github/gh-aw-firewall/agent", "latest", None); let mut script = format!( "set -eo pipefail\n\ diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 56544739..0bd248e0 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -6056,10 +6056,12 @@ fn test_supply_chain_full_reroutes_all_artifacts() { !compiled.contains("github.com/github/gh-aw-firewall/releases"), "AWF GitHub release URLs must be gone in feed mode" ); - // (b) No GHCR image references remain. + // (b) No GHCR image *pulls* remain — every pull comes from the internal + // registry. The local `:latest` aliases intentionally keep the GHCR names + // that AWF resolves by default with `--skip-pull` (see (d2) below). assert!( - !compiled.contains("ghcr.io"), - "GHCR image references must be gone in registry mode" + !compiled.contains("docker pull ghcr.io"), + "no image may be pulled from GHCR in registry mode" ); // (c) Standard ADO tasks are present for the binary mirror. @@ -6081,6 +6083,14 @@ fn test_supply_chain_full_reroutes_all_artifacts() { compiled.contains("feed: my-project/my-internal-feed"), "DownloadPackage@1 must target the configured feed" ); + // Auth is hoisted to one NuGetAuthenticate@1 per job, not per artifact, so + // there are strictly fewer auth steps than DownloadPackage@1 steps. + let auth_count = compiled.matches("- task: NuGetAuthenticate@1").count(); + let download_count = compiled.matches("- task: DownloadPackage@1").count(); + assert!( + auth_count < download_count, + "NuGetAuthenticate@1 should be hoisted per-job (got {auth_count} auth vs {download_count} downloads)" + ); // (d) Internal registry rewrite + ACR auth for images. assert!( @@ -6096,6 +6106,23 @@ fn test_supply_chain_full_reroutes_all_artifacts() { "MCPG image must be rewritten onto the internal registry (pull + docker run)" ); + // (d2) The local `:latest` aliases must be tagged under the GHCR names AWF + // resolves by default with `--skip-pull` — tagged from the internally + // pulled image, never pulled from GHCR. Regression guard for the firewall + // failing to find its images at runtime. + assert!( + compiled.contains( + "docker tag myacr.azurecr.io/github/gh-aw-firewall/squid:0.27.3 ghcr.io/github/gh-aw-firewall/squid:latest" + ), + "AWF squid image must be re-tagged to the GHCR :latest name AWF expects" + ); + assert!( + compiled.contains( + "docker tag myacr.azurecr.io/github/gh-aw-firewall/agent:0.27.3 ghcr.io/github/gh-aw-firewall/agent:latest" + ), + "AWF agent image must be re-tagged to the GHCR :latest name AWF expects" + ); + // (e) Checksum verification is retained. assert!( compiled.contains("sha256sum -c"), From 8584d57b3a5bf0d31847b96d07930baf76b33d64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 05:11:03 +0000 Subject: [PATCH 3/4] feat(compile): allow full internal registry base path for image mirror Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/front-matter.md | 2 +- docs/supply-chain.md | 27 ++++++---- .../content/docs/reference/supply-chain.mdx | 20 ++++--- src/compile/agentic_pipeline.rs | 52 +++++++++++-------- src/compile/types.rs | 27 +++++++--- src/secure.rs | 17 ++++++ src/validate.rs | 35 +++++++++++++ tests/compiler_tests.rs | 22 +++++--- tests/fixtures/supply-chain-agent.md | 2 +- 9 files changed, 152 insertions(+), 52 deletions(-) diff --git a/docs/front-matter.md b/docs/front-matter.md index f243ce84..dc44b7b0 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -177,7 +177,7 @@ supply-chain: # optional internal supply-chain mirror (see docs name: my-project/my-feed # feed name or project/feed; scalar `feed: my-feed` shorthand also works service-connection: feed-conn # optional; omit for same-org feeds (uses $(System.AccessToken)) registry: # mirror AWF/MCPG images from an internal ACR - name: myacr.azurecr.io # ACR login server + name: myacr.azurecr.io/mirror # registry host or base path (artifact names kept under it) service-connection: acr-conn # REQUIRED when registry is set (ACR has no System.AccessToken path) service-connection: shared-conn # optional shared fallback for whichever target omits its own parameters: # optional ADO runtime parameters (surfaced in UI when queuing a run) diff --git a/docs/supply-chain.md b/docs/supply-chain.md index fbf76469..f56f8d7f 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -27,7 +27,7 @@ supply-chain: name: my-project/my-feed # feed name or "project/feed" service-connection: feed-conn # optional (see Authentication) registry: # mirrors images #4 - name: myacr.azurecr.io # ACR login server + name: myacr.azurecr.io/mirror # registry host or base path service-connection: acr-conn # required when registry is set service-connection: shared-conn # optional shared fallback for both targets ``` @@ -114,14 +114,23 @@ matching `checksums.txt`. ### Registry images -The registry must host the AWF and MCPG images under the **same repository -paths** (GHCR path minus the `ghcr.io/` host prefix), at the **same tags**: - -| Internal reference | -|--------------------| -| `/github/gh-aw-firewall/squid:` | -| `/github/gh-aw-firewall/agent:` | -| `/github/gh-aw-mcpg:v` | +`registry.name` is a registry **host or base path** — teams generally cannot +publish under GHCR's `github/...` namespace, so the original GHCR prefix is +**not** preserved. Only the **artifact name** (the final image-name segment) +is kept, placed directly under the configured base path at the **same tag**: + +| GHCR source | Internal reference (base path ``) | +|-------------|---------------------------------------------| +| `ghcr.io/github/gh-aw-firewall/squid:` | `/squid:` | +| `ghcr.io/github/gh-aw-firewall/agent:` | `/agent:` | +| `ghcr.io/github/gh-aw-mcpg:v` | `/gh-aw-mcpg:v` | + +`` may be a bare host (`myacr.azurecr.io`) or a host with an +arbitrary namespace path (`myacr.azurecr.io/oss-mirror`, +`contoso.azurecr.io/team/oss/mirror`). The contract is only that the artifact +names (`squid`, `agent`, `gh-aw-mcpg`) and tags remain unchanged under that +path. `az acr login` derives the ACR registry name from the host portion of +the base path. ## Examples diff --git a/site/src/content/docs/reference/supply-chain.mdx b/site/src/content/docs/reference/supply-chain.mdx index dba9fe44..86a6180e 100644 --- a/site/src/content/docs/reference/supply-chain.mdx +++ b/site/src/content/docs/reference/supply-chain.mdx @@ -29,7 +29,7 @@ supply-chain: name: my-project/my-feed # feed name or "project/feed" service-connection: feed-conn # optional (see Authentication) registry: # mirrors images #4 - name: myacr.azurecr.io # ACR login server + name: myacr.azurecr.io/mirror # registry host or base path service-connection: acr-conn # required when registry is set service-connection: shared-conn # optional shared fallback for both targets ``` @@ -84,13 +84,19 @@ NuGet packages (same base names, **bare semver** versions, no leading `v`): | `awf` | AWF version | `awf-linux-x64`, `checksums.txt` | | `ado-script` | compiler version | `ado-script.zip`, `checksums.txt` | -Container images (same repository paths and tags, registry host swapped): +Container images. `registry.name` is a host **or base path** — the original +GHCR `github/...` prefix is **not** preserved; only the artifact name is kept, +directly under the base path at the same tag: -| Internal reference | -| --- | -| `/github/gh-aw-firewall/squid:` | -| `/github/gh-aw-firewall/agent:` | -| `/github/gh-aw-mcpg:v` | +| GHCR source | Internal reference | +| --- | --- | +| `ghcr.io/github/gh-aw-firewall/squid:` | `/squid:` | +| `ghcr.io/github/gh-aw-firewall/agent:` | `/agent:` | +| `ghcr.io/github/gh-aw-mcpg:v` | `/gh-aw-mcpg:v` | + +`` may be a bare host (`myacr.azurecr.io`) or a host with a namespace +path (`myacr.azurecr.io/oss-mirror`). The contract is only that the artifact +names (`squid`, `agent`, `gh-aw-mcpg`) and tags stay unchanged under it. `sha256sum -c checksums.txt` verification is preserved on the internal branch, so the mirror must ship the matching `checksums.txt`. diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 68312e74..f38dff2b 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -1152,33 +1152,43 @@ fn checkout_self_step() -> Step { /// `ghcr.io/github/gh-aw-firewall/squid`), `tag` the image tag. When /// `registry` is `None` the GHCR reference is returned unchanged. /// +/// The internal registry may have an entirely different namespace than GHCR +/// (teams generally cannot publish under `github/...`), so only the original +/// **artifact name** — the final path segment of `base` (`squid`, `agent`, +/// `gh-aw-mcpg`) — is preserved directly under the configured registry base +/// path. This is the contract: artifact names stay the same, the prefix is +/// whatever the user provides. +/// /// Centralised so the pre-pull step and the `docker run` invocation in /// `start_mcpg_step` cannot drift on the rewritten reference. fn image_ref(base: &str, tag: &str, registry: Option<&str>) -> String { match registry { Some(reg) => { - let path = base.strip_prefix("ghcr.io/").unwrap_or(base); - format!("{reg}/{path}:{tag}") + let name = base.rsplit('/').next().unwrap_or(base); + format!("{reg}/{name}:{tag}") } None => format!("{base}:{tag}"), } } -/// Derive the ACR registry name (used by `az acr login --name`) from a login -/// server. Strips a trailing `.azurecr.io` when present; otherwise returns the -/// portion before the first `.` (falling back to the whole value). -fn acr_registry_name(login_server: &str) -> &str { - login_server - .strip_suffix(".azurecr.io") - .or_else(|| login_server.split('.').next()) - .unwrap_or(login_server) +/// Derive the ACR registry name (used by `az acr login --name`) from a +/// registry base path. Takes the host portion (before the first `/`), then +/// strips a trailing `.azurecr.io` when present; otherwise returns the portion +/// before the first `.` (falling back to the whole host). +fn acr_registry_name(registry_base: &str) -> &str { + let host = registry_base.split('/').next().unwrap_or(registry_base); + host.strip_suffix(".azurecr.io") + .or_else(|| host.split('.').next()) + .unwrap_or(host) } /// `AzureCLI@2` step that runs `az acr login` against an internal registry so /// subsequent `docker pull` calls in the same job are authenticated. Uses the /// resolved registry service connection (an ARM/Azure service connection). -fn acr_login_step(login_server: &str, connection: &str) -> TaskStep { - let name = acr_registry_name(login_server); +/// `registry_base` is the configured registry host or base path; the ACR name +/// is derived from its host portion. +fn acr_login_step(registry_base: &str, connection: &str) -> TaskStep { + let name = acr_registry_name(registry_base); TaskStep::new("AzureCLI@2", "Authenticate to internal container registry") .with_input("azureSubscription", connection) .with_input("scriptType", "bash") @@ -1462,10 +1472,10 @@ fn download_awf_step(supply_chain: Option<&SupplyChainConfig>) -> Vec { fn prepull_images_step(include_mcpg: bool, supply_chain: Option<&SupplyChainConfig>) -> Vec { let registry = supply_chain.and_then(|sc| sc.registry.as_ref()); - let registry_host = registry.map(|r| r.name.as_str()); + let registry_base = registry.map(|r| r.name.as_str()); - let squid = image_ref("ghcr.io/github/gh-aw-firewall/squid", AWF_VERSION, registry_host); - let agent = image_ref("ghcr.io/github/gh-aw-firewall/agent", AWF_VERSION, registry_host); + let squid = image_ref("ghcr.io/github/gh-aw-firewall/squid", AWF_VERSION, registry_base); + let agent = image_ref("ghcr.io/github/gh-aw-firewall/agent", AWF_VERSION, registry_base); // The local `:latest` aliases must ALWAYS carry the GHCR image names that // AWF resolves by default when invoked with `--skip-pull` (run_agent_step // passes no `--awf-*-image` flags). Tagging them onto the internal @@ -1484,7 +1494,7 @@ fn prepull_images_step(include_mcpg: bool, supply_chain: Option<&SupplyChainConf docker tag {agent} {agent_latest}\n" ); let display = if include_mcpg { - let mcpg = image_ref(MCPG_IMAGE, &format!("v{MCPG_VERSION}"), registry_host); + let mcpg = image_ref(MCPG_IMAGE, &format!("v{MCPG_VERSION}"), registry_base); script.push_str(&format!("docker pull {mcpg}\n")); format!("Pre-pull AWF and MCPG container images (v{AWF_VERSION})") } else { @@ -1495,11 +1505,11 @@ fn prepull_images_step(include_mcpg: bool, supply_chain: Option<&SupplyChainConf // When using an internal registry, authenticate before pulling so the // job's docker daemon (shared with the subsequent `docker run` of MCPG) // can reach the registry. - if let (Some(host), Some(conn)) = ( - registry_host, + if let (Some(base), Some(conn)) = ( + registry_base, supply_chain.and_then(|sc| sc.registry_connection()), ) { - steps.push(Step::Task(acr_login_step(host, conn))); + steps.push(Step::Task(acr_login_step(base, conn))); } steps.push(Step::Bash(bash(display, script))); steps @@ -1554,10 +1564,10 @@ fn start_mcpg_step( debug_pipeline: bool, supply_chain: Option<&SupplyChainConfig>, ) -> Result { - let registry_host = supply_chain + let registry_base = supply_chain .and_then(|sc| sc.registry.as_ref()) .map(|r| r.name.as_str()); - let mcpg_image_v = image_ref(MCPG_IMAGE, &format!("v{MCPG_VERSION}"), registry_host); + let mcpg_image_v = image_ref(MCPG_IMAGE, &format!("v{MCPG_VERSION}"), registry_base); // Build the docker-env block as additional `-e VAR=...` lines, one per // line, joined with `\n ` (newline + 2-space continuation indent to // match the surrounding `-e MCP_GATEWAY_*` lines). When no extensions diff --git a/src/compile/types.rs b/src/compile/types.rs index a4de3333..119b66ca 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -967,12 +967,15 @@ pub struct FeedConfig { pub service_connection: Option, } -/// A registry target. Accepts either a bare scalar (the ACR login server) or -/// an object `{ name, service-connection }`. +/// A registry target. Accepts either a bare scalar (the registry host or base +/// path) or an object `{ name, service-connection }`. #[derive(Debug, Clone)] pub struct RegistryConfig { - /// ACR login server, e.g. `myacr.azurecr.io`. - pub name: crate::secure::HostName, + /// Internal container-registry host or base path, e.g. + /// `myacr.azurecr.io` or `myacr.azurecr.io/mirror`. The mirrored images + /// keep their original artifact names (`squid`, `agent`, `gh-aw-mcpg`) + /// directly under this path. + pub name: crate::secure::RegistryRef, /// Optional per-target service connection (overrides the top-level one). pub service_connection: Option, } @@ -1016,14 +1019,14 @@ impl<'de> Deserialize<'de> for RegistryConfig { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Obj { - name: crate::secure::HostName, + name: crate::secure::RegistryRef, #[serde(default, rename = "service-connection")] service_connection: Option, } #[derive(Deserialize)] #[serde(untagged)] enum Repr { - Scalar(crate::secure::HostName), + Scalar(crate::secure::RegistryRef), Obj(Obj), } Ok(match Repr::deserialize(deserializer)? { @@ -2046,6 +2049,18 @@ mod tests { assert!(ok.validate().is_ok()); } + #[test] + fn test_supply_chain_registry_accepts_base_path() { + // A registry may be a host with an arbitrary namespace path; the + // mirrored images keep their artifact names directly under it. + let sc = parse_supply_chain( + "supply-chain:\n registry:\n name: myacr.azurecr.io/oss-mirror\n service-connection: acr-conn", + ); + let registry = sc.registry.as_ref().unwrap(); + assert_eq!(registry.name.as_str(), "myacr.azurecr.io/oss-mirror"); + assert!(sc.validate().is_ok()); + } + #[test] fn test_supply_chain_feed_only_validates() { let sc = parse_supply_chain("supply-chain:\n feed: my-feed"); diff --git a/src/secure.rs b/src/secure.rs index b12cdb49..30cb3b14 100644 --- a/src/secure.rs +++ b/src/secure.rs @@ -31,6 +31,7 @@ //! - [`ArtifactName`] — an ADO artifact / attachment name. //! - [`Identifier`] — an engine agent/model identifier. //! - [`HostName`] — a DNS-style hostname. +//! - [`RegistryRef`] — a container-registry host or base path. //! - [`Version`] — a version string (`1.2.3`, `latest`). //! //! New safe-output tools that accept paths or identifiers should type those @@ -293,6 +294,22 @@ validated_string! { } } +validated_string! { + /// An internal container-registry base path (host plus optional + /// namespace path, e.g. `myacr.azurecr.io/mirror`). + RegistryRef, "registry", |value: &str, label: &str| { + if validate::is_valid_registry_ref(value) { + Ok(()) + } else { + anyhow::bail!( + "{label} '{value}' must be a registry host or base path \ + containing only [A-Za-z0-9._/-] (no '..', no '//', no \ + leading/trailing '/')" + ) + } + } +} + validated_string! { /// An Azure DevOps service-connection name or GUID. ServiceConnection, "service-connection", |value: &str, label: &str| { diff --git a/src/validate.rs b/src/validate.rs index 0f745e22..f9301ae6 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -101,6 +101,25 @@ pub fn is_valid_feed_ref(s: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/')) } +/// Validate an internal container-registry base path. +/// +/// Accepts a registry host optionally followed by one or more path segments +/// (e.g. `myacr.azurecr.io`, `myacr.azurecr.io/mirror`, +/// `contoso.azurecr.io/team/oss/mirror`). Unlike a feed reference this permits +/// more than one `/` separator, because a registry namespace can be arbitrarily +/// deep. Allowlist is `[A-Za-z0-9._/-]`; rejects empty strings, `..` traversal, +/// leading/trailing `/`, and `//`. The strict charset blocks shell +/// metacharacters in case the value is interpolated into a generated step. +pub fn is_valid_registry_ref(s: &str) -> bool { + !s.is_empty() + && !s.contains("..") + && !s.contains("//") + && !s.starts_with('/') + && !s.ends_with('/') + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/')) +} + /// Validate an Azure DevOps service-connection name or GUID. /// /// Connection display names legitimately contain spaces and assorted @@ -668,6 +687,22 @@ mod tests { assert!(!is_valid_feed_ref("inject;rm")); } + #[test] + fn test_is_valid_registry_ref() { + assert!(is_valid_registry_ref("myacr.azurecr.io")); + assert!(is_valid_registry_ref("myacr.azurecr.io/mirror")); + assert!(is_valid_registry_ref("contoso.azurecr.io/team/oss/mirror")); // multi-segment + assert!(is_valid_registry_ref("localhost")); + assert!(!is_valid_registry_ref("")); + assert!(!is_valid_registry_ref("/leading")); + assert!(!is_valid_registry_ref("trailing/")); + assert!(!is_valid_registry_ref("double//slash")); + assert!(!is_valid_registry_ref("../escape")); + assert!(!is_valid_registry_ref("has space")); + assert!(!is_valid_registry_ref("inject;rm")); + assert!(!is_valid_registry_ref("host:443")); // ports not allowed + } + #[test] fn test_is_valid_service_connection() { assert!(is_valid_service_connection("acr-conn")); diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 0bd248e0..acea3019 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -6092,18 +6092,26 @@ fn test_supply_chain_full_reroutes_all_artifacts() { "NuGetAuthenticate@1 should be hoisted per-job (got {auth_count} auth vs {download_count} downloads)" ); - // (d) Internal registry rewrite + ACR auth for images. + // (d) Internal registry rewrite + ACR auth for images. The configured + // registry base path (`myacr.azurecr.io/oss-mirror`) is an arbitrary + // namespace — the original GHCR prefix (`github/...`) is NOT preserved; + // only the artifact name (`squid`/`agent`/`gh-aw-mcpg`) sits directly under + // the base path. ACR login derives the registry name from the host. assert!( compiled.contains("- task: AzureCLI@2") && compiled.contains("az acr login --name myacr"), "ACR login must be emitted before docker pull in registry mode" ); assert!( - compiled.contains("docker pull myacr.azurecr.io/github/gh-aw-firewall/squid:"), - "AWF images must be pulled from the internal registry" + compiled.contains("docker pull myacr.azurecr.io/oss-mirror/squid:"), + "AWF images must be pulled from the internal registry base path (artifact name only)" ); assert!( - compiled.contains("myacr.azurecr.io/github/gh-aw-mcpg:"), - "MCPG image must be rewritten onto the internal registry (pull + docker run)" + compiled.contains("myacr.azurecr.io/oss-mirror/gh-aw-mcpg:"), + "MCPG image must be rewritten onto the internal registry base path (pull + docker run)" + ); + assert!( + !compiled.contains("myacr.azurecr.io/oss-mirror/github/"), + "the original GHCR `github/...` prefix must not be carried under the internal base path" ); // (d2) The local `:latest` aliases must be tagged under the GHCR names AWF @@ -6112,13 +6120,13 @@ fn test_supply_chain_full_reroutes_all_artifacts() { // failing to find its images at runtime. assert!( compiled.contains( - "docker tag myacr.azurecr.io/github/gh-aw-firewall/squid:0.27.3 ghcr.io/github/gh-aw-firewall/squid:latest" + "docker tag myacr.azurecr.io/oss-mirror/squid:0.27.3 ghcr.io/github/gh-aw-firewall/squid:latest" ), "AWF squid image must be re-tagged to the GHCR :latest name AWF expects" ); assert!( compiled.contains( - "docker tag myacr.azurecr.io/github/gh-aw-firewall/agent:0.27.3 ghcr.io/github/gh-aw-firewall/agent:latest" + "docker tag myacr.azurecr.io/oss-mirror/agent:0.27.3 ghcr.io/github/gh-aw-firewall/agent:latest" ), "AWF agent image must be re-tagged to the GHCR :latest name AWF expects" ); diff --git a/tests/fixtures/supply-chain-agent.md b/tests/fixtures/supply-chain-agent.md index 25727416..e8efc424 100644 --- a/tests/fixtures/supply-chain-agent.md +++ b/tests/fixtures/supply-chain-agent.md @@ -6,7 +6,7 @@ supply-chain: name: my-project/my-internal-feed service-connection: feed-conn registry: - name: myacr.azurecr.io + name: myacr.azurecr.io/oss-mirror service-connection: acr-conn --- From ec36cf82c5bac13f9f602af760cf31624e963e1a Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 18 Jun 2026 10:21:23 +0100 Subject: [PATCH 4/4] fix(compile): harden service-connection validation and version-agnostic image-tag test Address Rust PR review feedback: - Tests: replace hardcoded AWF_VERSION (0.27.3) in the :latest re-tag assertions with version-agnostic split contains() checks, so an AWF_VERSION bump can't cause spurious failures in this binary-only crate. - Security: is_valid_service_connection now rejects ADO expression / pipeline- command sequences ($( , ${{ , $[ , ##vso[ , ##[) so a connection value cannot be expanded as a pipeline variable at queue time, matching the codebase's reject_pipeline_injection convention. - Docs: note the Azure Private Link caveat for acr_registry_name / registry.name (use the canonical *.azurecr.io login server) in the function comment and docs/supply-chain.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/supply-chain.md | 7 +++++++ src/compile/agentic_pipeline.rs | 6 ++++++ src/validate.rs | 16 +++++++++++++--- tests/compiler_tests.rs | 14 +++++++------- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/docs/supply-chain.md b/docs/supply-chain.md index f56f8d7f..f9a34835 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -89,6 +89,13 @@ A container registry (ACR) cannot be accessed with $(System.AccessToken). The registry connection is an ARM / Azure service connection (the same kind used by `permissions:`), passed to `AzureCLI@2` as `azureSubscription`. +> **Private Link note:** the ACR name passed to `az acr login --name` is derived +> from the host portion of `registry.name`, assuming the standard +> `.azurecr.io` login server. If your ACR is reached over Azure Private +> Link with a custom domain (e.g. `myacr.internal.contoso.com`), set +> `registry.name` to the canonical `*.azurecr.io` login server so the derived +> registry name is correct. + ## What the feed and registry must contain Versions stay **pinned by the generating compiler** — the internal mirror must diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index f38dff2b..f0336e3d 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -1175,6 +1175,12 @@ fn image_ref(base: &str, tag: &str, registry: Option<&str>) -> String { /// registry base path. Takes the host portion (before the first `/`), then /// strips a trailing `.azurecr.io` when present; otherwise returns the portion /// before the first `.` (falling back to the whole host). +/// +/// NOTE: this assumes the standard `.azurecr.io` login-server hostname. +/// For ACR accessed over Azure Private Link with a custom domain (e.g. +/// `myacr.internal.contoso.com`), the `.split('.').next()` fallback may not +/// yield the registry name `az acr login --name` expects — configure +/// `registry.name` with the canonical `*.azurecr.io` login server in that case. fn acr_registry_name(registry_base: &str) -> &str { let host = registry_base.split('/').next().unwrap_or(registry_base); host.strip_suffix(".azurecr.io") diff --git a/src/validate.rs b/src/validate.rs index f9301ae6..4c35d362 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -125,9 +125,11 @@ pub fn is_valid_registry_ref(s: &str) -> bool { /// Connection display names legitimately contain spaces and assorted /// punctuation, so this is a deny-list rather than an allowlist: it rejects /// empty strings, anything over 256 characters, control characters -/// (including newlines), and quote characters. Values flow into YAML task -/// inputs (`azureSubscription`, `nuGetServiceConnections`, -/// `containerRegistry`) where these characters could break out of the scalar. +/// (including newlines), quote characters, and ADO expression / pipeline-command +/// sequences (`$(`, `${{`, `$[`, `##vso[`, `##[`). Values flow into YAML task +/// inputs (`azureSubscription`, `nuGetServiceConnections`, `containerRegistry`) +/// where these characters could break out of the scalar or be expanded as a +/// pipeline variable at queue time. pub fn is_valid_service_connection(s: &str) -> bool { !s.is_empty() && s.len() <= 256 @@ -135,6 +137,8 @@ pub fn is_valid_service_connection(s: &str) -> bool { && !s.contains('"') && !s.contains('`') && !s.chars().any(|c| c.is_control()) + && !contains_ado_expression(s) + && !contains_pipeline_command(s) } /// Characters allowed in individual engine.args entries. @@ -714,6 +718,12 @@ mod tests { assert!(!is_valid_service_connection("with\"quote")); assert!(!is_valid_service_connection("with`tick")); assert!(!is_valid_service_connection(&"x".repeat(257))); + // ADO expressions / pipeline commands must be rejected so the value + // cannot be expanded as a pipeline variable at queue time. + assert!(!is_valid_service_connection("$(my.shared.conn)")); + assert!(!is_valid_service_connection("${{ variables.conn }}")); + assert!(!is_valid_service_connection("conn$[variables.x]")); + assert!(!is_valid_service_connection("##vso[task.setvariable]")); } #[test] diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index acea3019..76e2d290 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -6117,17 +6117,17 @@ fn test_supply_chain_full_reroutes_all_artifacts() { // (d2) The local `:latest` aliases must be tagged under the GHCR names AWF // resolves by default with `--skip-pull` — tagged from the internally // pulled image, never pulled from GHCR. Regression guard for the firewall - // failing to find its images at runtime. + // failing to find its images at runtime. Use version-agnostic split + // assertions so an AWF_VERSION bump (unimportable in a binary-only crate) + // does not break the test. assert!( - compiled.contains( - "docker tag myacr.azurecr.io/oss-mirror/squid:0.27.3 ghcr.io/github/gh-aw-firewall/squid:latest" - ), + compiled.contains("docker tag myacr.azurecr.io/oss-mirror/squid:") + && compiled.contains(" ghcr.io/github/gh-aw-firewall/squid:latest"), "AWF squid image must be re-tagged to the GHCR :latest name AWF expects" ); assert!( - compiled.contains( - "docker tag myacr.azurecr.io/oss-mirror/agent:0.27.3 ghcr.io/github/gh-aw-firewall/agent:latest" - ), + compiled.contains("docker tag myacr.azurecr.io/oss-mirror/agent:") + && compiled.contains(" ghcr.io/github/gh-aw-firewall/agent:latest"), "AWF agent image must be re-tagged to the GHCR :latest name AWF expects" );