From b0cb52305e4bfb0fdc4eb64fe9a07b67c4784119 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 31 Jul 2026 20:44:52 +0100 Subject: [PATCH 01/42] fix(compile): correct az devops authentication claims in prompt and docs The Azure CLI extension's injected prompt told agents that `az devops`, `az pipelines`, `az repos`, and `az boards` are "authenticated automatically from \ when the pipeline declares `permissions: read:`" and that list operations "Just Work". None of that is true: `permissions.read` authenticates the first-party Azure DevOps MCP backend and never populates `AZURE_DEVOPS_EXT_PAT` in the agent sandbox. Agents therefore burned turns on `az devops` calls that could only ever fail, and the docs pointed operators at `az login` as the fix - which would put a real Azure credential inside the sandbox, the exact outcome the threat model forbids. State the boundary instead: the extension ships the binary, not a credential. Direct the agent to the `azure-devops` MCP tools for authenticated reads and to the `missing-tool` safe output otherwise, and tell it explicitly not to sign in. The prompt-anchor test asserted on `AZURE_DEVOPS_EXT_PAT`, so it locked in the false claim; it now anchors on the auth boundary wording. Refs #1652, #1717. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- README.md | 49 +++++++++++++++-------------- docs/front-matter.md | 2 +- docs/mcp.md | 6 +++- docs/tools.md | 26 ++++++--------- src/compile/extensions/azure_cli.rs | 41 ++++++++++++------------ tests/compiler_tests.rs | 8 +++-- tests/safe-outputs/README.md | 2 +- tests/safe-outputs/REGISTERED.md | 2 +- tests/safe-outputs/azure-cli.md | 16 ++++------ 9 files changed, 76 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 212856bdd..a5aea63fa 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ DevOps pipeline built around three core security stages: │ │ │ │ │ │ │ • Runs inside AWF │ │ • Reviews proposed │ │ • Creates PRs │ │ network sandbox │ │ actions for safety│ │ • Creates work items │ -│ • Read-only ADO token │ │ • Checks for prompt │ │ • Write ADO token │ +│ • Scoped ADO tools │ │ • Checks for prompt │ │ • Write ADO token │ │ • Produces safe │ │ injection, leaks │ │ • Never exposed to │ │ output proposals │ │ │ │ the agent │ └────────────────────────┘ └──────────────────────┘ └───────────────────────┘ @@ -176,42 +176,45 @@ Push both files to your Azure DevOps repository. ### Step 3: Set Up ARM Service Connections for Permissions This is the most important configuration step. Azure DevOps does not support -fine-grained PAT scoping — tokens are either read or read-write across the -project. To maintain security isolation between the agent and the executor, -**you need two separate ARM service connections**: +an AAD token whose "read-only" status is implied by the ARM service-connection +name. Azure DevOps authorizes the identity separately from its Azure RBAC +scope. Configure only the connections the workflow needs and grant their +underlying identities the minimum Azure DevOps permissions. -#### Why Two Connections? +#### Connection Roles | | Read Connection | Write Connection | |---|---|---| -| **Used by** | Stage 1 — the AI agent | Stage 3 — the safe outputs executor | -| **Purpose** | Query ADO APIs (work items, repos, PRs) | Create PRs, work items, link artifacts | -| **Exposed to agent?** | ✅ Yes (inside network sandbox) | ❌ Never | +| **Used by** | Stage 1 trusted ADO MCP backend | Stage 3 safe outputs executor | +| **Purpose** | Query ADO APIs through configured MCP tools | Create PRs, work items, link artifacts | +| **Exposed to agent?** | Raw token: no; MCP tools: yes | No | | **Token variable** | `SC_READ_TOKEN` | `SC_WRITE_TOKEN` | | **Front matter field** | `permissions.read` | `permissions.write` | -The agent runs in a network-isolated sandbox (AWF) with only the read token. -Even if the agent were compromised or prompt-injected, it cannot perform write -operations. Write actions are only executed in Stage 3 (`SafeOutputs`) -after threat analysis, using a completely separate token that the agent never -sees. +The raw Stage 1 token is passed to the trusted Azure DevOps MCP backend, not to +the Agent process or direct `az devops` commands. The current MCP backend still +relies on the identity's Azure DevOps permissions, so operators must configure +that identity as least-privileged. Write actions belong in Stage 3 +(`SafeOutputs`) after threat analysis. #### Creating the Service Connections 1. **Navigate** to **Project Settings → Service connections → New service connection** 2. Choose **Azure Resource Manager → Service principal (automatic)** (or manual if your organization requires it) -3. Create two connections: +3. Create the connections your workflow needs: **Read connection** (e.g., `ado-agent-read`): - Scope: subscription or resource group level - - Grants: the ability to mint read-only ADO-scoped tokens - - Used by: the agent job to call `az account get-access-token` with the - ADO resource ID (`499b84ac-1321-427f-aa17-267ca6975798`) + - Used by: the Agent job to mint an ADO-audience token for the trusted + Azure DevOps MCP backend (`499b84ac-1321-427f-aa17-267ca6975798`) + - Required ADO setup: grant the underlying identity only the Azure DevOps + read permissions the workflow needs; the ARM scope does not enforce this **Write connection** (e.g., `ado-agent-write`): - Scope: subscription or resource group level - - Grants: the ability to mint read-write ADO-scoped tokens + - Used to mint an ADO-audience token whose effective permissions come from + the underlying identity's Azure DevOps grants - Used by: the executor job to create PRs, work items, etc. 4. **Reference them** in your agent front matter: @@ -231,12 +234,12 @@ sees. #### Permission Combinations -| Configuration | Agent can read ADO? | Safe outputs can write? | +| Configuration | Trusted ADO MCP can authenticate? | Safe outputs can write? | |---|---|---| -| Both `read` + `write` | ✅ | ✅ (via ARM-minted token) | -| Only `read` | ✅ | ✅ (via `$(System.AccessToken)`) | -| Only `write` | ❌ | ✅ (via ARM-minted token) | -| Neither (default) | ❌ | ✅ (via `$(System.AccessToken)`) | +| Both `read` + `write` | Yes, when `tools.azure-devops` is enabled | Yes (via ARM-minted token) | +| Only `read` | Yes, when `tools.azure-devops` is enabled | Yes (via `$(System.AccessToken)`) | +| Only `write` | No | Yes (via ARM-minted token) | +| Neither (default) | No | Yes (via `$(System.AccessToken)`) | ### Step 4: Authorize the Pipeline diff --git a/docs/front-matter.md b/docs/front-matter.md index f7c9a336d..627cdd67c 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -218,7 +218,7 @@ network: # optional network policy (standalone target only # variable-groups: # optional: import ADO Library variable groups (standalone/1es only) # - My Variable Group # each entry must be the exact ADO Library group name (see "Variable Groups" section) permissions: # optional ADO access token configuration (see docs/network.md#permissions-ado-access-tokens) - read: my-read-arm-connection # ARM service connection for read-only ADO access (Stage 1 agent) + read: my-read-arm-connection # ARM SC for Stage 1 trusted ADO MCP auth; raw token is not in Agent env write: my-write-arm-connection # OPTIONAL ARM SC for Stage 3 executor writes. # Default: executor uses $(System.AccessToken). # Set this only for cross-org writes or diff --git a/docs/mcp.md b/docs/mcp.md index 6087fa59c..bbdce674d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -70,7 +70,11 @@ env: STATIC_CONFIG: "some-value" # Literal value embedded in config ``` -When `permissions.read` is configured, the compiler automatically maps `SC_READ_TOKEN` → `AZURE_DEVOPS_EXT_PAT` on the MCPG container, so agents can access ADO APIs without manual wiring. +For the first-party `tools.azure-devops` integration, the compiler maps +`SC_READ_TOKEN` to `ADO_MCP_AUTH_TOKEN` on MCPG, and MCPG passes that value to +the trusted ADO MCP child. This automatic mapping does not apply to arbitrary +user-defined `mcp-servers:` entries and does not populate +`AZURE_DEVOPS_EXT_PAT` in the Agent sandbox. ## Example: Azure DevOps MCP with Authentication diff --git a/docs/tools.md b/docs/tools.md index 56bfadae0..808185449 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -86,7 +86,9 @@ When enabled, the compiler: - Auto-infers org from the git remote URL at compile time (overridable via `org:` field) - Fails compilation if org cannot be determined (no explicit override and no ADO git remote) -> **Note:** `AZURE_DEVOPS_EXT_PAT` is a separate variable — it is injected by AWF into the agent sandbox for use by `az devops` CLI subcommands (see [Built-in CLIs — Azure CLI](#azure-cli-az) below), not by this extension for the MCP container. +> **Note:** the first-party MCP uses `ADO_MCP_AUTH_TOKEN`. The compiler does +> not inject `AZURE_DEVOPS_EXT_PAT` or another Azure credential into the Agent +> sandbox for direct CLI use. ## Built-in CLIs @@ -140,24 +142,16 @@ preventing "told to use `az`, fails with command not found" loops. | 1ES self-hosted pool with `azure-cli` | Same as above | | 1ES self-hosted pool *without* `az` | Pipeline runs; warning in ADO log; `az` is `command not found` inside the sandbox | -**Auth scope (important).** The compiler does not authenticate `az` for -general use. Two paths are supported: - -1. **`az devops *` subcommands** (work items, repos, pipelines, etc.) - are automatically authenticated via `AZURE_DEVOPS_EXT_PAT`, which - the compiler populates inside AWF whenever `permissions.read` is - configured. No extra steps needed. -2. **General `az` / ARM / Graph commands** (`az account get-access-token`, - `az resource ...`, `az ad ...`, etc.) require their own - authentication. The agent has no inherited cloud identity; you - must `az login` explicitly (e.g. via a federated identity flow you - provision yourself) before calling these commands. +**Auth scope (important).** The compiler exposes the binary but does not +authenticate direct `az` commands. This includes `az devops`, ARM, and Graph +subcommands. When configured, use `tools.azure-devops` for authenticated ADO +reads. Do not run `az login` or inject Azure credentials into the Agent +sandbox; use SafeOutputs or request a supported tool instead. A daily smoke pipeline at [`tests/safe-outputs/azure-cli.md`](../tests/safe-outputs/azure-cli.md) -exercises this wiring (calls `az --version` and `az devops project list` -against the host org) — see its compiled lock file for the exact -generated YAML. +exercises binary/subcommand availability without claiming authenticated direct +ADO access. ### GitHub CLI (`gh`) diff --git a/src/compile/extensions/azure_cli.rs b/src/compile/extensions/azure_cli.rs index 593b5503b..4e7dccaf4 100644 --- a/src/compile/extensions/azure_cli.rs +++ b/src/compile/extensions/azure_cli.rs @@ -52,11 +52,10 @@ use crate::compile::ir::step::{BashStep, Step}; /// can't reach and a command that doesn't resolve is harmless and /// keeps the compiled YAML deterministic across runner types). /// -/// **Auth.** `az devops` subcommands read `AZURE_DEVOPS_EXT_PAT` (set -/// inside AWF when `permissions.read` is configured). General `az` -/// commands (`az account get-access-token`, `az resource ...`, Graph -/// calls) require separate authentication and are out of scope for this -/// extension. +/// **Auth.** This extension only exposes the binary. It does not inject an +/// Azure or Azure DevOps credential into the agent sandbox. +/// `permissions.read` authenticates the optional first-party Azure DevOps MCP +/// backend; it does not populate `AZURE_DEVOPS_EXT_PAT` for direct CLI use. pub struct AzureCliExtension; impl CompilerExtension for AzureCliExtension { @@ -121,11 +120,11 @@ fn prompt_append_bash_step() -> BashStep { \n\ ## Azure CLI (`az`)\n\ \n\ -The Azure CLI is available inside this sandbox at `/usr/bin/az`. Prefer it over hand-rolled curl calls when it covers what you need:\n\ +The Azure CLI is available inside this sandbox at `/usr/bin/az`, but ado-aw does not inject an Azure or Azure DevOps credential into the sandbox:\n\ \n\ -- **Azure DevOps management** \u{2014} `az devops`, `az pipelines`, `az repos`, `az boards`. These are authenticated automatically from `$AZURE_DEVOPS_EXT_PAT` when the pipeline declares `permissions: read:`. List/inspect operations Just Work; write operations honour the PAT's scopes.\n\ -- **Azure Resource Manager** \u{2014} `az resource`, `az account`, `az group`. These require a separate Azure identity that ado-aw does not provision out of the box; sign in with `az login` using credentials supplied by another mechanism (e.g. a service connection writing them into your sandbox env) before invoking them.\n\ -- **Microsoft Graph** \u{2014} `az ad`, `az rest`. Same caveat as ARM.\n\ +- **Azure DevOps** \u{2014} `az devops`, `az pipelines`, `az repos`, and `az boards` are not pre-authenticated. When configured, use the `azure-devops` MCP tools for authenticated ADO reads.\n\ +- **Azure Resource Manager and Microsoft Graph** \u{2014} `az resource`, `az account`, `az group`, `az ad`, and authenticated `az rest` calls are not configured for agent use.\n\ +- Do not sign in or place Azure credentials in the sandbox. Request a supported tool instead.\n\ \n\ If a command you need isn't covered above, file a `missing-tool` safe output naming `azure-cli` so the operator can extend coverage rather than blocking on it silently.\n\ AZURE_CLI_PROMPT_EOF\n\ @@ -394,7 +393,7 @@ mod tests { #[test] fn test_azure_cli_prompt_append_step_has_advisory_anchors() { // Lock the advisory wording to the load-bearing parts: tool - // names, env var, and the missing-tool escape hatch. Style + // names, auth boundary, and the missing-tool escape hatch. Style // changes elsewhere in the prose are free; these anchors aren't. let ext = AzureCliExtension; let fm = fm(); @@ -405,7 +404,9 @@ mod tests { "Azure CLI", "/usr/bin/az", "az devops", - "AZURE_DEVOPS_EXT_PAT", + "not pre-authenticated", + "azure-devops", + "Do not sign in", "missing-tool", ] { assert!( @@ -414,17 +415,17 @@ mod tests { append.script ); } + assert!( + !append.script.contains("AZURE_DEVOPS_EXT_PAT"), + "the Agent prompt must not claim the direct CLI receives an ADO credential" + ); } #[test] fn test_azure_cli_prompt_append_uses_single_quoted_heredoc() { - // The advisory body contains `$AZURE_DEVOPS_EXT_PAT` and other - // literal dollar references. Single-quoting the heredoc - // delimiter (`<< 'DELIM'`) is what prevents bash from - // expanding them while building the prompt file. If anyone - // ever swaps to an unquoted heredoc, `$AZURE_DEVOPS_EXT_PAT` - // would be replaced by the runner's PAT value (a secret) and - // baked into the agent prompt — a real leak. + // Keep the prompt heredoc non-expanding. Future advisory text may + // contain environment-variable names, and changing this to an + // unquoted delimiter could bake a secret into the agent prompt. let ext = AzureCliExtension; let fm = fm(); let ctx = CompileContext::for_test(&fm); @@ -433,8 +434,8 @@ mod tests { assert!( append.script.contains("<< 'AZURE_CLI_PROMPT_EOF'"), "prompt-append heredoc delimiter must be single-quoted to \ - prevent expansion of $AZURE_DEVOPS_EXT_PAT and similar \ - literals inside the prompt body. Step:\n{}", + prevent expansion of environment references inside the prompt \ + body. Step:\n{}", append.script ); } diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index c6f2de9a9..076e37b09 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -5762,8 +5762,8 @@ fn test_default_pipeline_mounts_az_and_allows_azure_hosts() { // (1b) Conditional prompt-append step: when az is detected, the // agent prompt receives an Azure CLI advisory section so the - // agent knows az is on PATH, what it's good for, and the auth - // model. The step is gated by `condition: ne(variables['AW_AZ_MOUNTS'], '')` + // agent knows az is on PATH and that no Azure/ADO credential is + // injected. The step is gated by `condition: ne(variables['AW_AZ_MOUNTS'], '')` // so agents on runners WITHOUT az never see the advisory and // never try to call az. assert!( @@ -5796,7 +5796,9 @@ fn test_default_pipeline_mounts_az_and_allows_azure_hosts() { for anchor in [ "/usr/bin/az", "az devops", - "AZURE_DEVOPS_EXT_PAT", + "not pre-authenticated", + "azure-devops", + "Do not sign in", "missing-tool", ] { assert!( diff --git a/tests/safe-outputs/README.md b/tests/safe-outputs/README.md index d381d5a5a..d339eaa1f 100644 --- a/tests/safe-outputs/README.md +++ b/tests/safe-outputs/README.md @@ -27,7 +27,7 @@ five pipelines: | File | Purpose | | --- | --- | | `canary.md` / `canary.lock.yml` | Daily omnibus canary: the agent emits `noop` + `create-work-item` + `add-build-tag` in one run. Proves the full agentic loop with two distinct ADO write paths. | -| `azure-cli.md` / `azure-cli.lock.yml` | Daily: verifies the AWF az CLI extension is mounted, the `az devops` subcommand authenticates via `AZURE_DEVOPS_EXT_PAT`, and the sandbox can reach the ADO control plane. | +| `azure-cli.md` / `azure-cli.lock.yml` | Daily: verifies the AWF az CLI extension is mounted and the `az devops` command group is available. It does not claim authenticated direct ADO access. | | `noop-target.md` / `noop-target.lock.yml` | No-schedule target pipeline queued by the `queue-build` executor-e2e scenario (its ID feeds `E2E_QUEUE_PIPELINE_ID`). | | `janitor.md` / `janitor.lock.yml` | Weekly: prunes `ado-aw-smoke-*` artifacts (work items, branches, wiki pages, tags, PRs) older than 30 days from AgentPlayground. | | `smoke-failure-reporter.md` / `smoke-failure-reporter.lock.yml` | Daily ~04:30: queries the canary and azure-cli pipelines for failures and files `[smoke-failure] …` issues on `jamesadevine/ado-aw-issues` while canonical-repo credentials are unavailable. | diff --git a/tests/safe-outputs/REGISTERED.md b/tests/safe-outputs/REGISTERED.md index 9fad2c470..2d2acf5b2 100644 --- a/tests/safe-outputs/REGISTERED.md +++ b/tests/safe-outputs/REGISTERED.md @@ -9,7 +9,7 @@ cutover. | Fixture | Schedule | Pipeline ID | Notes | | --- | --- | --- | --- | | `canary.md` | `daily around 03:00` | `2545` | Omnibus: noop + create-work-item + add-build-tag in one agentic run. Proves Stage 1 → 2 → 3 end-to-end. | -| `azure-cli.md` | `daily around 03:00` | `2546` | Verifies AWF az CLI mount + ADO auth via `AZURE_DEVOPS_EXT_PAT`. | +| `azure-cli.md` | `daily around 03:00` | `2546` | Verifies the AWF az CLI mount and `az devops` command-group availability; direct ADO auth is not expected. | | `noop-target.md` | _no schedule_ | `2547` | Target of the `queue-build` executor-e2e scenario. `E2E_QUEUE_PIPELINE_ID=2547` on executor definition `2550`. | | `janitor.md` | `weekly on monday around 02:00` | `2548` | Prunes `ado-aw-smoke-*` artifacts older than 30 days. | | `smoke-failure-reporter.md` | `daily around 04:30` | `2549` | Files `[smoke-failure] …` issues on `jamesadevine/ado-aw-issues`. Requires the `ADO_AW_DEBUG_GITHUB_TOKEN` secret pipeline variable, **only on this pipeline**. | diff --git a/tests/safe-outputs/azure-cli.md b/tests/safe-outputs/azure-cli.md index 82d326fb6..2984b3eed 100644 --- a/tests/safe-outputs/azure-cli.md +++ b/tests/safe-outputs/azure-cli.md @@ -29,24 +29,20 @@ Steps (run each in turn using your bash tool): az --version | head -3 ``` -2. Confirm ADO subcommand auth works using `AZURE_DEVOPS_EXT_PAT` - (populated automatically when `permissions.read` is set). List up to - 3 projects from the current organization: +2. Confirm the Azure DevOps command group is installed and can render help. + This smoke does not expect direct ADO authentication: ``` - az devops project list \ - --organization "$(System.CollectionUri)" \ - --query 'value[0:3].name' \ - -o tsv + az devops -h | head -20 ``` - Capture the combined stdout/stderr (truncated to 400 characters if - longer) for the safe-output context below. + Capture the combined stdout/stderr (truncated to 400 characters if longer) + for the safe-output context below. 3. Call exactly one safe-output tool, `noop`, with: - context: a brief one-line proof-of-life containing the az version - string and the captured project-list output, prefixed with + string and command-group help output, prefixed with `ado-aw-smoke-$(Build.BuildId)-azure-cli:`. Do not call any other tool. After the safe output is emitted, stop. From cdc231618933abcf5974a483dd0cfef6acfc23e5 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 31 Jul 2026 20:47:32 +0100 Subject: [PATCH 02/42] feat(ado-proxy): add credential-isolated Azure DevOps policy proxy An ARM service connection's Azure RBAC scope does not constrain what its identity may do in Azure DevOps, so handing that credential to a Stage 1 agent grants whatever ADO permissions the identity happens to hold - which the workflow author neither chose nor can see. Exposing `az` directly cannot be made safe by configuration, because it depends on every consumer having scoped their ADO instance correctly. Take gh-aw's approach instead: a policy proxy that holds the credential so the agent never sees it. AWF points the agent's HTTP(S)_PROXY at a managed sidecar; Squid denies the protected ADO hosts to the agent, making the sidecar the only route to them. Stock `az`, curl, and the SDKs keep working unmodified. Deny-by-default and narrow on purpose: reads only, current organization/project/repository, 34 catalogued operations. Writes stay in SafeOutputs, where they are already reviewed. Catalog authored once, in Rust -------------------------------- The compiler emits the policy document and the sidecar consumes it, so the two must not diverge. `src/ado_proxy/catalog.rs` is authoritative; everything else is generated from it by `npm run codegen` - the JSON Schema, the TypeScript types the bundle compiles against, and a committed `catalog.gen.json` snapshot. A drift test re-runs the exporter and fails on any difference, and the bundle refuses to start if the mounted policy's `catalog_version` does not match the one compiled into it, so a stale policy fails closed rather than under-enforcing. Runtime is TypeScript, not Rust -------------------------------- A Rust implementation needs a TLS stack plus certificate minting (rustls + rcgen -> ring), which would make a native C toolchain a hard build requirement for the whole compiler. ado-aw is otherwise pure-Rust and must stay buildable without one. `ado-script` already ships 20 bundles through the same supply-chain mirror, and Node's built-in tls/http/net need no new dependency - the bundle has zero runtime deps. Enforcement ----------- Two paths, and only two. Non-protected destinations are byte-tunnelled to Squid untouched, so package feeds behave exactly as before. Protected destinations are TLS-terminated, normalized, authorized against the catalog, stripped of every client-supplied credential, and only then - after a complete allow decision - given the bearer. Deny-by-default is structural: unknown route, non-read method, unlisted query parameter, disabled capability, out-of-window api-version, or out-of-scope organization/project/repository all deny before the upstream is contacted, so a rejected request never exercises the credential. Request normalization refuses ambiguous targets rather than rewriting them, and the api-version is read from *both* the query string and the Accept header because ADO honours either - declaring one in each would otherwise let a request be checked as one operation and served as another. Two ADO endpoints (`az repos pr show`, `az boards work-item show`) are addressable by id alone, so their scope is validated against the response body; list endpoints are filtered. Response headers are allow-listed, so upstream Set-Cookie, WWW-Authenticate, and redirect Location never reach the agent. Denials return a WrappedException-shaped 403 that clients can surface, and infrastructure failures return 502 rather than 401/429/503, which msrest would retry. `runtime_available` stays false: nothing emits the sidecar or policy document yet, so authors must not be told the capability exists. Also extends `permissions.read` to accept the object form for explicit policy configuration, rejected at compile time until the runtime is wired. Tested with 139 tests including an end-to-end suite that drives the assembled server against a fake Squid and a fake Azure DevOps with a canary bearer, asserting the credential is injected on allowed reads and that denials never reach the upstream. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- .github/workflows/ado-script.yml | 8 +- AGENTS.md | 14 +- docs/ado-proxy-design.md | 233 ++++++ docs/ado-script.md | 54 ++ docs/cli.md | 15 +- docs/network.md | 29 +- scripts/ado-script/.gitignore | 1 + scripts/ado-script/package.json | 7 +- .../src/ado-proxy/api-version.test.ts | 113 +++ .../ado-script/src/ado-proxy/api-version.ts | 140 ++++ scripts/ado-script/src/ado-proxy/ca.ts | 177 +++++ .../src/ado-proxy/catalog-drift.test.ts | 142 ++++ .../ado-script/src/ado-proxy/catalog.gen.json | 616 ++++++++++++++++ .../ado-script/src/ado-proxy/catalog.test.ts | 115 +++ scripts/ado-script/src/ado-proxy/catalog.ts | 102 +++ .../ado-script/src/ado-proxy/config.test.ts | 205 ++++++ scripts/ado-script/src/ado-proxy/config.ts | 282 +++++++ .../ado-script/src/ado-proxy/headers.test.ts | 104 +++ scripts/ado-script/src/ado-proxy/headers.ts | 144 ++++ scripts/ado-script/src/ado-proxy/index.ts | 131 ++++ scripts/ado-script/src/ado-proxy/log.ts | 84 +++ .../ado-script/src/ado-proxy/policy.test.ts | 254 +++++++ scripts/ado-script/src/ado-proxy/policy.ts | 295 ++++++++ .../src/ado-proxy/proxy.e2e.test.ts | 592 +++++++++++++++ .../ado-script/src/ado-proxy/response.test.ts | 162 ++++ scripts/ado-script/src/ado-proxy/response.ts | 174 +++++ .../ado-script/src/ado-proxy/route.test.ts | 208 ++++++ scripts/ado-script/src/ado-proxy/route.ts | 252 +++++++ scripts/ado-script/src/ado-proxy/server.ts | 542 ++++++++++++++ .../ado-script/src/ado-proxy/token.test.ts | 61 ++ scripts/ado-script/src/ado-proxy/token.ts | 93 +++ scripts/ado-script/src/ado-proxy/upstream.ts | 104 +++ .../src/shared/ado-proxy-catalog.types.gen.ts | 57 ++ src/ado_proxy/catalog.rs | 696 ++++++++++++++++++ src/ado_proxy/mod.rs | 37 + src/compile/agentic_pipeline.rs | 4 +- src/compile/common.rs | 42 ++ src/compile/types.rs | 180 ++++- src/inspect/catalog.rs | 46 +- src/main.rs | 100 ++- src/mcp_author/mod.rs | 5 +- src/secure.rs | 110 +++ src/validate.rs | 21 + 43 files changed, 6680 insertions(+), 71 deletions(-) create mode 100644 docs/ado-proxy-design.md create mode 100644 scripts/ado-script/src/ado-proxy/api-version.test.ts create mode 100644 scripts/ado-script/src/ado-proxy/api-version.ts create mode 100644 scripts/ado-script/src/ado-proxy/ca.ts create mode 100644 scripts/ado-script/src/ado-proxy/catalog-drift.test.ts create mode 100644 scripts/ado-script/src/ado-proxy/catalog.gen.json create mode 100644 scripts/ado-script/src/ado-proxy/catalog.test.ts create mode 100644 scripts/ado-script/src/ado-proxy/catalog.ts create mode 100644 scripts/ado-script/src/ado-proxy/config.test.ts create mode 100644 scripts/ado-script/src/ado-proxy/config.ts create mode 100644 scripts/ado-script/src/ado-proxy/headers.test.ts create mode 100644 scripts/ado-script/src/ado-proxy/headers.ts create mode 100644 scripts/ado-script/src/ado-proxy/index.ts create mode 100644 scripts/ado-script/src/ado-proxy/log.ts create mode 100644 scripts/ado-script/src/ado-proxy/policy.test.ts create mode 100644 scripts/ado-script/src/ado-proxy/policy.ts create mode 100644 scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts create mode 100644 scripts/ado-script/src/ado-proxy/response.test.ts create mode 100644 scripts/ado-script/src/ado-proxy/response.ts create mode 100644 scripts/ado-script/src/ado-proxy/route.test.ts create mode 100644 scripts/ado-script/src/ado-proxy/route.ts create mode 100644 scripts/ado-script/src/ado-proxy/server.ts create mode 100644 scripts/ado-script/src/ado-proxy/token.test.ts create mode 100644 scripts/ado-script/src/ado-proxy/token.ts create mode 100644 scripts/ado-script/src/ado-proxy/upstream.ts create mode 100644 scripts/ado-script/src/shared/ado-proxy-catalog.types.gen.ts create mode 100644 src/ado_proxy/catalog.rs create mode 100644 src/ado_proxy/mod.rs diff --git a/.github/workflows/ado-script.yml b/.github/workflows/ado-script.yml index b52d4f304..51208bbf4 100644 --- a/.github/workflows/ado-script.yml +++ b/.github/workflows/ado-script.yml @@ -6,6 +6,8 @@ on: - "scripts/ado-script/**" - "src/compile/filter_ir.rs" - "src/compile/extensions/ado_script.rs" + - "src/ado_proxy/**" + - "src/main.rs" - "Cargo.toml" - "Cargo.lock" - ".github/workflows/ado-script.yml" @@ -20,6 +22,8 @@ on: - "scripts/ado-script/**" - "src/compile/filter_ir.rs" - "src/compile/extensions/ado_script.rs" + - "src/ado_proxy/**" + - "src/main.rs" - "Cargo.toml" - "Cargo.lock" - ".github/workflows/ado-script.yml" @@ -54,9 +58,9 @@ jobs: - name: Verify generated TypeScript is up to date run: | - if ! git diff --exit-code -- scripts/ado-script/src/shared/types.gen.ts scripts/ado-script/src/trigger-e2e/fact-catalog.gen.json; then + if ! git diff --exit-code -- scripts/ado-script/src/shared/types.gen.ts scripts/ado-script/src/trigger-e2e/fact-catalog.gen.json scripts/ado-script/src/shared/ado-proxy-catalog.types.gen.ts scripts/ado-script/src/ado-proxy/catalog.gen.json; then echo "" - echo "::error::Generated files are out of date with the Rust IR (types.gen.ts and/or fact-catalog.gen.json)." + echo "::error::Generated files are out of date with the Rust source (types.gen.ts, fact-catalog.gen.json, and/or the ado-proxy catalog artifacts)." echo "Run 'cd scripts/ado-script && npm run codegen' and commit the result." exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index 72ded968e..247b6abb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,9 +31,10 @@ repository. The pipeline yaml references the agent. Every compiled pipeline runs as three sequential jobs: 1. **Agent (Stage 1)** — runs the AI agent inside an AWF network-isolated - sandbox with a read-only ADO token. The agent produces *safe-output - proposals* (e.g. "create this PR", "comment on this work item") rather than - acting directly. + sandbox. When configured, a trusted Azure DevOps MCP backend holds the + Stage 1 ADO credential; the raw token is not injected into the Agent + process. The agent produces *safe-output proposals* (e.g. "create this PR", + "comment on this work item") rather than acting directly. 2. **Detection (Stage 2)** — by default, a separate agent inspects Stage 1's proposals for prompt injection, secret leaks, and other threats. Authors can configure or explicitly disable AI analysis under @@ -139,6 +140,9 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ ├── ado/ # Shared Azure DevOps REST helpers (auth, list/match/PATCH/POST) │ │ ├── mod.rs # Shared ADO REST helpers used by all lifecycle commands (`enable`, `disable`, `list`, `status`, `run`, `remove`, `secrets`) │ │ └── discovery.rs # Project-scope pipeline discovery (`--all-repos` / `--source` flags) +│ ├── ado_proxy/ # Authoritative Stage 1 ADO proxy policy (runtime ships as the `ado-proxy` ado-script bundle; see docs/ado-proxy-design.md) +│ │ ├── mod.rs # Module entry; why the runtime is TypeScript; the compiler/sidecar anti-divergence contract +│ │ └── catalog.rs # Versioned deny-by-default read-operation catalog (surfaced by `ado-aw catalog --kind ado-proxy`; exported to the bundle as schema + committed snapshot) │ ├── audit/ # `ado-aw audit` command — downloads pipeline artifacts and runs analyzers │ │ ├── mod.rs # Module entry; declares submodules; re-exports `model::*` and CLI helpers │ │ ├── cli.rs # CLI entry point for the `audit` subcommand @@ -274,6 +278,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ ├── executor-e2e/ # Stage 3 safe-output E2E test harness (not a bundle; runs deterministic scenarios against a real ADO project and files a GitHub issue on failure) │ ├── compiler-smoke-e2e/ # Deterministic compiler-candidate smoke E2E orchestrator (not a bundle): stages a compiler candidate, pushes to a short-lived `ado-aw-mirror` branch, queues the four FIXED "candidate lane" pipeline definitions, and asserts they go green. Consumes fixtures from `tests/compiler-smoke-e2e/`; built to `test-bin/` by `build:compiler-smoke-e2e`, listed in `NON_BUNDLE_DIRS`. │ ├── prepare-pr-base/ # create-pull-request preparer (bundled to prepare-pr-base.js): Agent mode uses ADO diff metadata + bounded dual-ref fallback to make the merge-base reachable; SafeOutputs mode fetches only the target worktree tip +│ ├── ado-proxy/ # Credential-isolated ADO policy proxy (bundled to ado-proxy.js). The one long-running bundle: AWF runs it as a managed sidecar. `catalog.gen.json` + `../shared/ado-proxy-catalog.types.gen.ts` are generated from Rust by `export-ado-proxy-catalog{,-schema}` and drift-guarded; a `catalog_version` mismatch fails closed at startup. │ ├── trigger-e2e/ # Test-only gate-spec / trigger-evaluation harness (not a bundle): mirrors Rust `Fact::ALL` in `gate-spec.ts`; `fact-catalog.gen.json` is generated by `export-fact-catalog` and drift-guarded by CI │ └── shared/ # Shared modules across bundles (auth, ado-client, env-facts, types.gen.ts) ├── tests/ # Integration tests and fixtures @@ -384,6 +389,9 @@ index to jump to the right page. - [`docs/network.md`](docs/network.md) — AWF network isolation, default allowed domains, ecosystem identifiers, blocking, and ADO `permissions:` service-connection model. +- [`docs/ado-proxy-design.md`](docs/ado-proxy-design.md) — + security contract and implementation design for credential-isolated + Stage 1 Azure DevOps HTTP access. - [`docs/extending.md`](docs/extending.md) — adding new CLI commands, compile targets, front-matter fields, typed IR extensions, safe-output tools, first-class tools, and runtimes; the `CompilerExtension` trait. diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md new file mode 100644 index 000000000..565dd1463 --- /dev/null +++ b/docs/ado-proxy-design.md @@ -0,0 +1,233 @@ +# Credential-Isolated Azure DevOps Proxy (`ado-proxy`) + +_Security contract and implementation design. The runtime described here is +implemented behind a hidden, pipeline-internal CLI surface, but it is not yet +wired into generated pipelines: `ado-aw catalog --kind ado-proxy` +still reports `runtime_available: false` until the compiler, credential, and +AWF wiring land._ + +## Why this is required + +`permissions.read` names an Azure Resource Manager service connection, but its +Azure subscription or resource-group scope does not make the underlying +identity read-only in Azure DevOps. An AAD token for the Azure DevOps audience +inherits that identity's Azure DevOps permissions. The compiler therefore +cannot safely treat the service-connection name or ARM scope as an +authorization boundary. + +The current implementation keeps `SC_READ_TOKEN` out of the Agent process and +passes it only to the trusted first-party Azure DevOps MCP backend. Direct +`az devops`, curl, and SDK calls are not authenticated. Issues +[#1652](https://github.com/githubnext/ado-aw/issues/1652) and +[#1717](https://github.com/githubnext/ado-aw/issues/1717) track the missing +credential-isolated direct HTTP path and the earlier documentation mismatch. + +## Scope + +The first production provider supports Azure DevOps Services reads for the +current organization, project, and repository. Broader scopes require explicit +configuration. The following remain outside this provider: + +- Azure Resource Manager, Microsoft Graph, and Azure data-plane APIs; +- Stage 1 mutations; +- credential-, token-, key-, SAS-, service-connection-, variable-secret-, and + secure-file-returning operations; +- Azure DevOps Server/on-premises and sovereign/custom clouds; +- git smart HTTP, artifacts and signed redirects, Analytics/OData, and broad + batch APIs until separately modeled. + +Writes remain SafeOutputs or future privileged executors. + +## Trust boundaries + +Trusted components are the Azure Pipelines host task, AWF, Squid, the managed +policy sidecar, MCPG, and the pinned container/runtime supply chain. The Agent, +its prompt, repository content, tool arguments, generated scripts, and all +client-provided HTTP headers and bodies are untrusted. + +The protected credential set is: + +- the identity behind `permissions.read`; +- workload-identity assertions and any `System.AccessToken` used to renew them; +- every Azure DevOps REST bearer minted from that identity; +- private token files and proxy CA private keys. + +Those values must never appear in Agent or Detection environment, argv, +`/proc`, files, mounts, prompts, MCP configuration or payloads, logs, or +published artifacts. Existing package-feed credentials created by +`PipAuthenticate`, `npmAuthenticate`, or `NuGetAuthenticate` are a separate, +explicitly out-of-scope path. + +## Network contract + +ado-aw uses AWF `--network-isolation`. `awf-net` is an internal Docker network; +the Agent has no direct internet route and there is no legacy iptables/DNAT +fallback. + +The target path is policy-first: + +1. AWF points Agent HTTP(S) proxy variables at a hardened managed sidecar. +2. The sidecar MITMs only compiler-owned Azure DevOps REST hosts. +3. Non-Azure-DevOps traffic is tunneled unchanged to Squid. +4. Approved Azure DevOps requests are also sent upstream through Squid. +5. Squid source ACLs deny protected Azure DevOps destinations when the Agent + tries to address Squid directly, but allow them from the policy sidecar. +6. Clearing proxy variables or opening a direct internet socket has no route. + +The sidecar is safe even when directly reachable from another `awf-net` peer: +it applies the same policy to every caller, is not a generic relay, and can +reach the internet only through Squid. + +## Authentication and TLS + +The Agent receives at most a fixed non-secret sentinel needed for client-side +preflight. The proxy removes all client authorization and injects the current +ADO bearer only after the request matches an allowed operation and resource +scope. + +The proxy creates an ephemeral interception CA. Its private key exists only on +sidecar tmpfs. AWF installs only the public certificate into supported client +trust stores. + +Production must support WIF renewal beyond the original assertion lifetime. +The expected trusted path requests a fresh assertion from +`$(System.OidcRequestUri)` using a host-task-only `$(System.AccessToken)`, +re-authenticates outside AWF, and atomically updates a proxy-only token file. +If this cannot be demonstrated without exposing identity material, rollout +stops rather than falling back to an Agent credential. + +This is an Azure Pipelines-supported pattern rather than a custom refresh +protocol. `AzureCLI@2` implements the same behavior behind its experimental +`keepAzSessionActive` input: for WIF connections it requests a new OIDC token +and repeats `az login --federated-token` on an interval. The proxy integration +must use `addSpnToEnvironment: false`; client ID, tenant ID, and service +connection ID are non-secret task metadata, while the raw OIDC assertion and +`System.AccessToken` remain trusted-task-only. + +## Authorization contract + +The operation catalog matches normalized host, method, route template, API +version, current organization/project/repository scope, and bounded +operation-specific request fields. It explicitly models read-like POSTs; +method alone never determines safety. + +The in-tree catalog can be inspected before runtime enablement with +`ado-aw catalog --kind ado-proxy --json`. Its +`runtime_available` field remains `false` until the credential and AWF wiring +are enabled. + +Unknown hosts, methods, routes, API versions, redirects, or body shapes fail +closed. Client authorization is never preferred over the proxy credential. +Required Azure DevOps discovery, `X-TFS-*`, session, continuation, and API +version headers are preserved. Denial responses must be proven not to trigger +unsafe retries or interactive sign-in behavior. + +Known credential-bearing endpoints are denied, but ordinary repository files, +work-item text, PR text, and build logs may still contain user-authored +secrets. The proxy limits API authority; it is not a general content +classification or exfiltration-prevention system. + +## Runtime implementation + +The proxy ships as **`ado-proxy`**, a TypeScript bundle in +`scripts/ado-script/`, packaged in `ado-script.zip` alongside the other +`ado-script` bundles and already covered by the `supply-chain:` mirror. AWF +runs it as the managed sidecar's entrypoint from the pinned AWF agent image, +the same entrypoint-override pattern SafeOutputs already uses. + +It is not a Rust subcommand. A Rust implementation would need a TLS stack plus +certificate minting (`rustls` + `rcgen` → `ring`), which would make a native C +toolchain a hard build requirement for the whole compiler; ado-aw is otherwise +pure-Rust and must stay buildable without one. Node's built-in `tls`, `http`, +and `net` modules cover the same ground with no new runtime dependency, and +match how AWF implements its own credential-isolating sidecars. + +Configuration follows the generic `AWF_POLICY_PROXY_*` contract AWF publishes +for any policy-proxy sidecar. No credential is ever passed through argv or the +environment: the bearer is read from a private, rotating token file, and the +policy document is a mounted read-only JSON file carrying the +`catalog_version` the bundle re-checks at startup, so a stale policy fails +closed. + +Request handling has exactly two paths: + +- **Non-protected destination.** For `CONNECT`, the proxy opens a tunnel + through Squid and byte-tunnels in both directions. It does not terminate + TLS, parse the payload, or touch the client's own credentials, so package + feeds and every other allowed host behave exactly as they do without the + sidecar. Absolute-form plain HTTP is relayed to Squid unchanged, because the + agent's `HTTP_PROXY` points here and refusing cleartext would silently break + `http://` package sources. +- **Protected destination.** The proxy terminates TLS with an ephemeral leaf + (ALPN pinned to `http/1.1`), normalizes the request, evaluates it against + the versioned catalog, drops every client credential and forwarding header, + and — only after a complete allow decision, and only for a protected + upstream — attaches the current bearer and sends the request through Squid. + Plain HTTP to a protected host, and `CONNECT` to a protected host on any + port other than 443, are denied outright. + +Request normalization is deliberately strict rather than lenient: a target +that would need rewriting to become safe is refused instead, so the bytes the +policy inspects are the bytes the upstream receives. Encoded path separators, +double encoding, traversal segments, control characters, and an `api-version` +that disagrees between the query string and the `Accept` header are all +denials. + +Fail-closed behavior is structural rather than advisory: + +- the only egress is the configured Squid URL, so a Squid outage is a `502` + and never a direct socket; +- the policy document must declare this bundle's catalog schema version, + carry no unrecognized key, and list every cataloged protected host — a host + missing from the policy would take the byte-tunnel path instead of being + policed, which is the one bypass the proxy exists to prevent; +- policy denials return a stable `403` with an Azure DevOps + `WrappedException`-shaped body (`message`, `typeKey`), so `az` and every + msrest-based SDK surface an actionable sentence, and with no `Location`, + `WWW-Authenticate`, `Set-Cookie`, or `Retry-After` header, so no client + retries a semantic request or falls into an interactive sign-in; +- credential and upstream failures return `502` with a different `typeKey`, + deliberately avoiding `401`/`429`/`503` because msrest retries those; an + Azure DevOps `203` sign-in page or `401` challenge is never relayed; +- response headers are allow-listed, so upstream `Set-Cookie`, + `WWW-Authenticate`, and redirect `Location` headers cannot reach the agent; +- response bodies are bounded by the operation's declared limit, and — for + organization-addressed reads — must prove they belong to the current project + and repository before any byte reaches the agent. + +Custody rules the implementation enforces: + +- the CA and its per-host leaves are minted at startup with the `openssl` + binary already present in the AWF agent image (Node can parse but not issue + X.509, and adding a certificate library would reintroduce the native + dependency this runtime exists to avoid). Every private key is written only + under the container tmpfs directory AWF mounts for this purpose; only the + public CA PEM is copied out, into the file AWF pre-creates and bind-mounts + read-only into the agent; +- the bearer is read from its private file, cached on the file's mtime and + size, so a rotation is observed on the next request and a removed or emptied + file immediately becomes an infrastructure failure rather than a stale + credential. It is applied to a copy of the sanitized header set *after* the + allow decision, so no code path can emit it for a denied request; +- the JSONL decision log is schema-versioned and carries only the timestamp, + request id, protected host, method, normalized operation id, decision, + machine-readable reason and short detail, upstream status class, latency, + response byte count, and the names of any credential headers the client + supplied and the proxy stripped. Raw paths, query values, headers, bodies, + and credentials have nowhere to go in the record type. + +## Production gates + +Default-on rollout requires evidence that: + +- stock `az`, curl, Python clients, and the ADO MCP can perform allowed scoped + reads with no real client credential; +- write, cross-scope, sensitive, unknown, alternate-host, direct-Squid, and + direct-socket requests do not reach the upstream operation; +- WIF renewal works after the original assertion expires; +- canary credentials are absent from Agent and Detection surfaces and + artifacts; +- package restore and non-ADO network behavior remain intact; +- all compile targets emit the same boundary; +- a released, pinned AWF image implements the managed proxy/CA path and required + internal mirrors contain that image. diff --git a/docs/ado-script.md b/docs/ado-script.md index 789d27fb7..576170be2 100644 --- a/docs/ado-script.md +++ b/docs/ado-script.md @@ -439,6 +439,60 @@ mirror of the IR — so the spec contract cannot drift between compiler and evaluator. CI enforces this with a `git diff --exit-code` step on the codegen output. +## `ado-proxy`: the same contract, applied to policy + +`ado-proxy.js` is the credential-isolated Azure DevOps policy proxy AWF runs as +a managed sidecar (see [`ado-proxy-design.md`](ado-proxy-design.md)). It is the +one bundle that is a **long-running server** rather than a single-shot step: it +starts before the agent and is torn down when the agent exits. + +Because the compiler *emits* the policy document and the bundle *consumes* it, +the two must not diverge. The catalog of permitted Azure DevOps operations is +authored once, in Rust (`src/ado_proxy/catalog.rs`), and everything else is +generated from it by `npm run codegen`: + +| Artefact | Produced by | Guards against | +|---|---|---| +| `schema/ado-proxy-catalog.schema.json` | `cargo run -- export-ado-proxy-catalog-schema` | — | +| `src/shared/ado-proxy-catalog.types.gen.ts` | `json2ts` over that schema | compiling against a stale *shape* | +| `src/ado-proxy/catalog.gen.json` | `cargo run -- export-ado-proxy-catalog` | a stale *policy* — `catalog-drift.test.ts` re-runs the exporter and fails on any diff | + +A third guard runs at execution time: the compiler stamps +`catalog_version` into the emitted policy document, and the bundle refuses to +start unless it matches the version compiled into it. A stale mounted policy +file therefore fails closed instead of silently under-enforcing. The same +startup check rejects an unrecognized policy key, and a policy whose +`protected_hosts` omits any catalogued host — either would let a host the +catalog expects to police take the unchecked byte-tunnel path instead. + +This mirrors the `export-gate-schema` / `export-fact-catalog` pattern above. + +### Modules inside `ado-proxy.js` + +Unlike the single-shot bundles, `ado-proxy` is split by security concern so +each boundary can be tested in isolation: + +| Module | Responsibility | +|---|---| +| `config.ts` | Parse argv / the generic `AWF_POLICY_PROXY_*` env contract and the mounted policy document. Fail-closed on anything unrecognized. | +| `catalog.ts` | Load the generated snapshot; canonicalize hosts (case, `host:port`, trailing DNS dot) for the protected-set check. | +| `route.ts` | Normalize the request target and match catalog route templates. Refuses ambiguous encodings rather than rewriting them. | +| `api-version.ts` | Resolve the API version from both the query string and the `Accept` header, and reject disagreement or an out-of-window value. | +| `policy.ts` | The allow/deny decision: method, denied family, route, capability, version, query, and scope. | +| `headers.ts` | Allow-list request and response headers; strip every client credential. | +| `token.ts` | Read the rotating bearer file, cached on mtime and size. | +| `ca.ts` | Mint the ephemeral CA and per-host leaves via `openssl`; publish only the public PEM. | +| `upstream.ts` | CONNECT through Squid — the sidecar's only route out. | +| `response.ts` | Bound and filter response bodies; validate organization-addressed reads against the pinned scope. | +| `log.ts` | The schema-versioned, sanitized JSONL decision stream. | +| `server.ts` | Wire the two request paths together. | + +`proxy.e2e.test.ts` exercises the assembled server against a fake Squid and a +fake Azure DevOps with a canary bearer, asserting both that an allowed read +carries the injected credential and that every denial is refused *before* the +upstream is contacted. It needs `openssl` on `PATH` (CI has it; on Windows the +test also looks in the Git for Windows toolchain). + ## Runtime stages inside `gate.js` `gate.js`'s entry point is `src/gate/index.ts`. It runs five stages, diff --git a/docs/cli.md b/docs/cli.md index 237759637..365ba62f7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -170,9 +170,10 @@ Both `--all-repos` and `--source` route through `ado-aw`'s `discover_ado_aw_pipe - `` - Path to the agent markdown file. - `--json` - Emit lint findings as structured JSON instead of the human-readable report. -- `catalog [--kind ] [--json]` - List the compiler's in-tree registries for scripting or discovery. +- `catalog [--kind ] [--json]` - List the compiler's in-tree registries for scripting or discovery. - `--kind <...>` - Restrict output to one category. When omitted, emits every category. - `--kind versions` - Emit the compiler's pinned **semver** versions (`copilot_cli`, `awf`, `mcpg`) as a single source of truth. CI reads these deterministically instead of scraping the Rust source, e.g. `ado-aw catalog --kind versions --json | jq -r '.versions.copilot_cli'`. + - `--kind ado-proxy` - Emit the versioned deny-by-default Stage 1 ADO read-policy catalog and whether its credential-isolated runtime is available. - `--json` - Emit the catalog as structured JSON instead of the human-readable report. ### Hidden Build-Time Tools @@ -187,6 +188,18 @@ These commands are not shown in `--help` but are available for contributors work - `--output, -o ` - Write the catalog to a file instead of stdout. Parent directories are created automatically. - Typical use: `cargo run -- export-fact-catalog --output scripts/ado-script/src/trigger-e2e/fact-catalog.gen.json` +### Hidden Pipeline-Internal Commands + +These commands are started by the pipeline itself (or by AWF on its behalf) and are not part of the authoring surface: + +> The credential-isolated Azure DevOps policy proxy is **not** an `ado-aw` +> subcommand. It ships as the `ado-proxy` TypeScript bundle in +> `scripts/ado-script/` (packaged in `ado-script.zip`, mirrored by +> `supply-chain:`), and AWF runs it as the managed sidecar's entrypoint. See +> [`docs/ado-proxy-design.md`](ado-proxy-design.md) for its configuration +> contract, and `ado-aw catalog --kind ado-proxy` for the versioned operation +> catalog it enforces. + ## Pipeline IR Reference The compiler builds typed Azure DevOps pipeline IR and lowers it through one YAML emitter. The canonical Setup → Agent → Detection → SafeOutputs → Teardown shape, plus the optional always-running Conclusion job when `safe-outputs:` is configured, lives in `agentic_pipeline.rs` (shared by every target); target-specific builders (`standalone_ir.rs`, `onees_ir.rs`, `job_ir.rs`, and `stage_ir.rs`) own only the per-target envelope (pipeline shape, template parameters, 1ES wrapping). diff --git a/docs/network.md b/docs/network.md index c881c0a42..33aa48b73 100644 --- a/docs/network.md +++ b/docs/network.md @@ -92,9 +92,10 @@ When (and only when) `AW_AZ_MOUNTS` is non-empty, a follow-up *Append Azure CLI prompt* step appends an Azure CLI advisory section to `/tmp/awf-tools/agent-prompt.md`. The agent reads the prompt on startup and learns that `az` is on PATH, what it's good for -(`az devops` autoauthed via `$AZURE_DEVOPS_EXT_PAT`, ARM and Graph -requiring separate auth), and the fallback path (`missing-tool` -safe output naming `azure-cli`). +(`az devops` and Azure commands are not pre-authenticated), the authenticated +ADO MCP alternative, and the fallback path (`missing-tool` safe output naming +`azure-cli`). The advisory tells the agent not to sign in or place Azure +credentials in the sandbox. The step is gated by `condition: ne(variables['AW_AZ_MOUNTS'], '')`, which reuses the same pipeline variable the detection step writes. @@ -187,9 +188,12 @@ network: ## Permissions (ADO Access Tokens) -ADO does not support fine-grained permissions — there are two access levels: -blanket read and blanket write. The executor (Stage 3) always has a -write-capable token; what changes is its *source* and *attribution*: +The ARM service-connection scope does not determine what its identity may do in +Azure DevOps. `permissions.read` and `permissions.write` describe intended +pipeline roles and token placement; operators must separately grant each +underlying identity the minimum Azure DevOps permissions. The executor +(Stage 3) always has a write-capable token; what changes is its *source* and +*attribution*: | Source | When | Identity | | ----------------------------------- | --------------------------------------------- | ----------------------------------------------- | @@ -221,7 +225,7 @@ Operators can scope further per-pipeline by editing the build definition's ```yaml permissions: - read: my-read-arm-connection # Stage 1 agent — read-only ADO access + read: my-read-arm-connection # Stage 1 trusted ADO MCP credential # write: my-write-arm-connection # Optional — see below ``` @@ -241,9 +245,12 @@ agents. Set `permissions.write` only when you need: ### Security Model -- **`permissions.read`**: Mints a read-only ADO-scoped token given to the - agent inside the AWF sandbox (Stage 1). The agent can query ADO APIs but - cannot write. +- **`permissions.read`**: Mints an ADO-audience token for the trusted + first-party Azure DevOps MCP backend when `tools.azure-devops` is enabled. + The raw token is not injected into the Agent process or direct Azure CLI. + Azure DevOps permissions on the underlying identity remain the authorization + boundary until the policy proxy described in + [`ado-proxy-design.md`](ado-proxy-design.md) is implemented. - **`permissions.write` (optional)**: Mints a write-capable ADO-scoped token used **only** by the executor in Stage 3 (`SafeOutputs` job). Overrides the default `$(System.AccessToken)` for write operations. Never exposed @@ -255,7 +262,7 @@ agents. Set `permissions.write` only when you need: ### Examples ```yaml -# Default: agent can read ADO, executor writes via $(System.AccessToken). +# Trusted ADO MCP can authenticate; executor writes via $(System.AccessToken). permissions: read: my-read-sc diff --git a/scripts/ado-script/.gitignore b/scripts/ado-script/.gitignore index f50cd05ea..85b8cf336 100644 --- a/scripts/ado-script/.gitignore +++ b/scripts/ado-script/.gitignore @@ -15,6 +15,7 @@ approval-summary.js conclusion.js github-app-token.js prepare-pr-base.js +ado-proxy.js schema *.tsbuildinfo test-bin diff --git a/scripts/ado-script/package.json b/scripts/ado-script/package.json index e95f33e99..d558cb907 100644 --- a/scripts/ado-script/package.json +++ b/scripts/ado-script/package.json @@ -7,8 +7,8 @@ "node": ">=20.0.0" }, "scripts": { - "build": "npm run codegen && npm run clean && npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && npm run build:exec-context-manual && npm run build:exec-context-pipeline && npm run build:exec-context-ci-push && npm run build:exec-context-workitem && npm run build:exec-context-schedule && npm run build:exec-context-pr-checks && npm run build:exec-context-repo && npm run build:conclusion && npm run build:approval-summary && npm run build:github-app-token && npm run build:prepare-pr-base", - "clean": "node -e \"const fs=require('node:fs'); fs.rmSync('.ado-build',{recursive:true,force:true}); for (const n of ['gate','import','exec-context-pr','exec-context-pr-synth','exec-context-manual','exec-context-pipeline','exec-context-ci-push','exec-context-workitem','exec-context-schedule','exec-context-pr-checks','exec-context-repo','conclusion','approval-summary','github-app-token','prepare-pr-base']) fs.rmSync(n+'.js',{force:true});\"", + "build": "npm run codegen && npm run clean && npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && npm run build:exec-context-manual && npm run build:exec-context-pipeline && npm run build:exec-context-ci-push && npm run build:exec-context-workitem && npm run build:exec-context-schedule && npm run build:exec-context-pr-checks && npm run build:exec-context-repo && npm run build:conclusion && npm run build:approval-summary && npm run build:github-app-token && npm run build:prepare-pr-base && npm run build:ado-proxy", + "clean": "node -e \"const fs=require('node:fs'); fs.rmSync('.ado-build',{recursive:true,force:true}); for (const n of ['gate','import','exec-context-pr','exec-context-pr-synth','exec-context-manual','exec-context-pipeline','exec-context-ci-push','exec-context-workitem','exec-context-schedule','exec-context-pr-checks','exec-context-repo','conclusion','approval-summary','github-app-token','prepare-pr-base','ado-proxy']) fs.rmSync(n+'.js',{force:true});\"", "build:gate": "ncc build src/gate/index.ts -o .ado-build/gate -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/gate/index.js','gate.js'); fs.rmSync('.ado-build/gate',{recursive:true,force:true});\"", "build:import": "ncc build src/import/index.ts -o .ado-build/import -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/import/index.js','import.js'); fs.rmSync('.ado-build/import',{recursive:true,force:true});\"", "build:exec-context-pr": "ncc build src/exec-context-pr/index.ts -o .ado-build/exec-context-pr -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/exec-context-pr/index.js','exec-context-pr.js'); fs.rmSync('.ado-build/exec-context-pr',{recursive:true,force:true});\"", @@ -24,11 +24,12 @@ "build:approval-summary": "ncc build src/approval-summary/index.ts -o .ado-build/approval-summary -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/approval-summary/index.js','approval-summary.js'); fs.rmSync('.ado-build/approval-summary',{recursive:true,force:true});\"", "build:github-app-token": "ncc build src/github-app-token/index.ts -o .ado-build/github-app-token -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/github-app-token/index.js','github-app-token.js'); fs.rmSync('.ado-build/github-app-token',{recursive:true,force:true});\"", "build:prepare-pr-base": "ncc build src/prepare-pr-base/index.ts -o .ado-build/prepare-pr-base -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/prepare-pr-base/index.js','prepare-pr-base.js'); fs.rmSync('.ado-build/prepare-pr-base',{recursive:true,force:true});\"", + "build:ado-proxy": "ncc build src/ado-proxy/index.ts -o .ado-build/ado-proxy -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/ado-proxy/index.js','ado-proxy.js'); fs.rmSync('.ado-build/ado-proxy',{recursive:true,force:true});\"", "build:executor-e2e": "ncc build src/executor-e2e/index.ts -o .ado-build/executor-e2e -m -t && node -e \"const fs=require('node:fs'); fs.mkdirSync('test-bin',{recursive:true}); fs.copyFileSync('.ado-build/executor-e2e/index.js','test-bin/executor-e2e.js'); fs.rmSync('.ado-build/executor-e2e',{recursive:true,force:true});\"", "build:trigger-e2e": "ncc build src/trigger-e2e/index.ts -o .ado-build/trigger-e2e -m -t && node -e \"const fs=require('node:fs'); fs.mkdirSync('test-bin',{recursive:true}); fs.copyFileSync('.ado-build/trigger-e2e/index.js','test-bin/trigger-e2e.js'); fs.rmSync('.ado-build/trigger-e2e',{recursive:true,force:true});\"", "build:compiler-smoke-e2e": "ncc build src/compiler-smoke-e2e/index.ts -o .ado-build/compiler-smoke-e2e -m -t && node -e \"const fs=require('node:fs'); fs.mkdirSync('test-bin',{recursive:true}); fs.copyFileSync('.ado-build/compiler-smoke-e2e/index.js','test-bin/compiler-smoke-e2e.js'); fs.rmSync('.ado-build/compiler-smoke-e2e',{recursive:true,force:true});\"", "build:check": "ls -lh gate.js && wc -c gate.js", - "codegen": "node -e \"require('node:fs').mkdirSync('schema', { recursive: true })\" && cargo run --quiet --manifest-path ../../Cargo.toml -- export-gate-schema --output schema/gate-spec.schema.json && npx json2ts schema/gate-spec.schema.json -o src/shared/types.gen.ts --bannerComment \"// AUTO-GENERATED from Rust IR via cargo run -- export-gate-schema. Do not edit; run npm run codegen.\" && cargo run --quiet --manifest-path ../../Cargo.toml -- export-fact-catalog --output src/trigger-e2e/fact-catalog.gen.json", + "codegen": "node -e \"require('node:fs').mkdirSync('schema', { recursive: true })\" && cargo run --quiet --manifest-path ../../Cargo.toml -- export-gate-schema --output schema/gate-spec.schema.json && npx json2ts schema/gate-spec.schema.json -o src/shared/types.gen.ts --bannerComment \"// AUTO-GENERATED from Rust IR via cargo run -- export-gate-schema. Do not edit; run npm run codegen.\" && cargo run --quiet --manifest-path ../../Cargo.toml -- export-fact-catalog --output src/trigger-e2e/fact-catalog.gen.json && cargo run --quiet --manifest-path ../../Cargo.toml -- export-ado-proxy-catalog-schema --output schema/ado-proxy-catalog.schema.json && npx json2ts schema/ado-proxy-catalog.schema.json -o src/shared/ado-proxy-catalog.types.gen.ts --bannerComment \"// AUTO-GENERATED from Rust via cargo run -- export-ado-proxy-catalog-schema. Do not edit; run npm run codegen.\" && cargo run --quiet --manifest-path ../../Cargo.toml -- export-ado-proxy-catalog --output src/ado-proxy/catalog.gen.json", "test": "vitest run", "test:smoke": "npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && npm run build:exec-context-manual && npm run build:exec-context-pipeline && npm run build:exec-context-ci-push && npm run build:exec-context-workitem && npm run build:exec-context-schedule && npm run build:exec-context-pr-checks && npm run build:exec-context-repo && npm run build:conclusion && npm run build:approval-summary && npm run build:github-app-token && npm run build:prepare-pr-base && vitest run -c vitest.config.smoke.ts", "lint": "echo TODO", diff --git a/scripts/ado-script/src/ado-proxy/api-version.test.ts b/scripts/ado-script/src/ado-proxy/api-version.test.ts new file mode 100644 index 000000000..d76b2e775 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/api-version.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { + API_VERSION_ABSENT, + ApiVersionError, + apiVersionFromAccept, + parseApiVersion, + resolveApiVersion, +} from "./api-version.js"; + +const RANGE = "5.0..=7.2; preview allowed"; + +describe("parseApiVersion", () => { + it("parses plain and preview versions", () => { + expect(parseApiVersion("7.1")).toMatchObject({ major: 7, minor: 1, preview: false }); + expect(parseApiVersion("7.1-preview")).toMatchObject({ preview: true }); + expect(parseApiVersion("7.1-preview.2")).toMatchObject({ preview: true }); + }); + + it("rejects anything else", () => { + for (const bad of ["7", "7.1.2", "v7.1", "7.1-alpha", ""]) { + expect(() => parseApiVersion(bad)).toThrow(ApiVersionError); + } + }); +}); + +describe("apiVersionFromAccept", () => { + it("reads the parameter from a media type", () => { + expect( + apiVersionFromAccept("application/json;api-version=7.1;excludeUrls=true"), + ).toBe("7.1"); + }); + + it("is case-insensitive about the parameter name", () => { + expect(apiVersionFromAccept("application/json;API-Version=6.0")).toBe("6.0"); + }); + + it("returns undefined when absent", () => { + expect(apiVersionFromAccept("application/json")).toBeUndefined(); + expect(apiVersionFromAccept(undefined)).toBeUndefined(); + }); + + it("rejects conflicting versions across media types", () => { + // Otherwise the policy validates one version while the upstream honours + // another. + expect(() => + apiVersionFromAccept("application/json;api-version=7.1, text/plain;api-version=1.0"), + ).toThrow(ApiVersionError); + }); +}); + +describe("resolveApiVersion", () => { + it("accepts a version supplied only in the query", () => { + expect(resolveApiVersion(RANGE, [["api-version", "7.1"]], undefined)?.raw).toBe("7.1"); + }); + + it("accepts a version supplied only in Accept", () => { + expect( + resolveApiVersion(RANGE, [], "application/json;api-version=6.0")?.raw, + ).toBe("6.0"); + }); + + it("accepts matching versions in both places", () => { + expect( + resolveApiVersion(RANGE, [["api-version", "7.0"]], "application/json;api-version=7.0") + ?.raw, + ).toBe("7.0"); + }); + + it("rejects disagreement between the query and Accept", () => { + expect(() => + resolveApiVersion( + RANGE, + [["api-version", "7.1"]], + "application/json;api-version=3.0", + ), + ).toThrow(ApiVersionError); + }); + + it("rejects duplicate conflicting query parameters", () => { + expect(() => + resolveApiVersion(RANGE, [ + ["api-version", "7.1"], + ["api-version", "1.0"], + ], undefined), + ).toThrow(ApiVersionError); + }); + + it("requires a version on versioned operations", () => { + expect(() => resolveApiVersion(RANGE, [], undefined)).toThrow(ApiVersionError); + }); + + it("rejects versions outside the catalog window", () => { + // Old preview surfaces routinely expose fields and routes the catalog was + // never written against. + expect(() => resolveApiVersion(RANGE, [["api-version", "1.0"]], undefined)).toThrow( + ApiVersionError, + ); + expect(() => resolveApiVersion(RANGE, [["api-version", "9.0"]], undefined)).toThrow( + ApiVersionError, + ); + }); + + it("requires absence on discovery OPTIONS operations", () => { + expect(resolveApiVersion(API_VERSION_ABSENT, [], undefined)).toBeUndefined(); + expect(() => + resolveApiVersion(API_VERSION_ABSENT, [["api-version", "7.1"]], undefined), + ).toThrow(ApiVersionError); + expect(() => + resolveApiVersion(API_VERSION_ABSENT, [], "application/json;api-version=7.1"), + ).toThrow(ApiVersionError); + }); +}); diff --git a/scripts/ado-script/src/ado-proxy/api-version.ts b/scripts/ado-script/src/ado-proxy/api-version.ts new file mode 100644 index 000000000..9d090ac04 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/api-version.ts @@ -0,0 +1,140 @@ +/** + * Azure DevOps API-version extraction and validation. + * + * Azure DevOps accepts the version in two places, and clients use both: `az` + * and the REST SDKs put it in the `Accept` header + * (`application/json;api-version=7.1;excludeUrls=true`), while curl and most + * hand-written callers put it in the query string. The proxy must read both, + * because honouring only one lets a request declare a benign version in the + * place the policy looks while the upstream honours a different one. + */ +import { API_VERSION_MAX, API_VERSION_MIN } from "./catalog.js"; + +/** Marker used by catalog operations that must carry no API version at all. */ +export const API_VERSION_ABSENT = "absent"; + +export class ApiVersionError extends Error {} + +/** A parsed `major.minor[-preview[.n]]` version. */ +export interface ApiVersion { + readonly major: number; + readonly minor: number; + readonly preview: boolean; + /** The original text, for logging. */ + readonly raw: string; +} + +const VERSION = /^(\d{1,3})\.(\d{1,3})(-preview(?:\.\d{1,3})?)?$/; + +/** Parse a version string, or throw with the offending text. */ +export function parseApiVersion(raw: string): ApiVersion { + const match = VERSION.exec(raw.trim()); + if (match === null) { + throw new ApiVersionError(`unrecognized api-version ${JSON.stringify(raw)}`); + } + return { + major: Number(match[1]), + minor: Number(match[2]), + preview: match[3] !== undefined, + raw: raw.trim(), + }; +} + +/** True when the version falls inside the catalog's supported window. */ +export function isSupported(version: ApiVersion): boolean { + const [minMajor, minMinor] = API_VERSION_MIN; + const [maxMajor, maxMinor] = API_VERSION_MAX; + const value = version.major * 1000 + version.minor; + return value >= minMajor * 1000 + minMinor && value <= maxMajor * 1000 + maxMinor; +} + +/** + * Pull the `api-version` parameter out of an `Accept` header. + * + * Handles the multi-media-type form clients send, and treats a version that + * differs between media types as a conflict rather than picking one. + */ +export function apiVersionFromAccept(accept: string | undefined): string | undefined { + if (accept === undefined) return undefined; + const found = new Set(); + for (const mediaType of accept.split(",")) { + for (const parameter of mediaType.split(";").slice(1)) { + const equals = parameter.indexOf("="); + if (equals === -1) continue; + const name = parameter.slice(0, equals).trim().toLowerCase(); + if (name !== "api-version") continue; + found.add(parameter.slice(equals + 1).trim().replace(/^"|"$/g, "")); + } + } + if (found.size === 0) return undefined; + if (found.size > 1) { + throw new ApiVersionError("Accept header declares conflicting api-versions"); + } + return [...found][0]; +} + +/** Pull `api-version` out of parsed query pairs. */ +export function apiVersionFromQuery( + query: readonly (readonly [string, string])[], +): string | undefined { + const values = new Set( + query + .filter(([name]) => name.toLowerCase() === "api-version") + .map(([, value]) => value), + ); + if (values.size === 0) return undefined; + if (values.size > 1) { + throw new ApiVersionError("query string declares conflicting api-versions"); + } + return [...values][0]; +} + +/** + * Resolve and validate the effective API version for a request. + * + * `expected` is the catalog operation's `api_version` field: either + * {@link API_VERSION_ABSENT} for discovery `OPTIONS`, or the range marker for + * everything else. Throws {@link ApiVersionError} on any disagreement, + * absence-violation, or out-of-window version — all of which are denials. + */ +export function resolveApiVersion( + expected: string, + query: readonly (readonly [string, string])[], + accept: string | undefined, +): ApiVersion | undefined { + const fromQuery = apiVersionFromQuery(query); + const fromAccept = apiVersionFromAccept(accept); + + if (expected === API_VERSION_ABSENT) { + if (fromQuery !== undefined || fromAccept !== undefined) { + throw new ApiVersionError( + "this operation must be sent without an api-version", + ); + } + return undefined; + } + + if (fromQuery !== undefined && fromAccept !== undefined && fromQuery !== fromAccept) { + // Declaring one version in the query and another in Accept lets a request + // be policy-checked as one operation and served as another. + throw new ApiVersionError( + "api-version in the query string and Accept header disagree", + ); + } + + const raw = fromQuery ?? fromAccept; + if (raw === undefined) { + throw new ApiVersionError("this operation requires an api-version"); + } + + const version = parseApiVersion(raw); + if (!isSupported(version)) { + const [minMajor, minMinor] = API_VERSION_MIN; + const [maxMajor, maxMinor] = API_VERSION_MAX; + throw new ApiVersionError( + `api-version ${version.raw} is outside the supported range ` + + `${minMajor}.${minMinor}-${maxMajor}.${maxMinor}`, + ); + } + return version; +} diff --git a/scripts/ado-script/src/ado-proxy/ca.ts b/scripts/ado-script/src/ado-proxy/ca.ts new file mode 100644 index 000000000..9662f7d75 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/ca.ts @@ -0,0 +1,177 @@ +/** + * Ephemeral interception CA and per-host leaf certificates. + * + * Node cannot *create* X.509 certificates: `node:crypto` can generate key pairs + * and parse certificates, but has no issuance API. The options are a native + * crypto dependency (which is what pushed this runtime off Rust in the first + * place) or the `openssl` binary that is already present in the AWF agent image + * and already used by AWF's own ssl-bump setup. This module takes the second + * path, so the bundle keeps zero runtime dependencies. + * + * Key custody: every private key is written under a caller-supplied directory + * that must be container tmpfs. Only the CA's *public* certificate is ever + * copied out, into the pre-created file AWF installs into the agent's trust + * stores. + */ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export class CaError extends Error {} + +/** A minted leaf certificate for one protected host. */ +export interface Leaf { + readonly key: string; + readonly cert: string; +} + +/** Materials produced by {@link mintCa}. */ +export interface CaMaterials { + /** PEM of the CA certificate. Safe to publish. */ + readonly caCertPem: string; + /** Leaf key/cert per protected host, keyed by lowercase hostname. */ + readonly leaves: ReadonlyMap; +} + +const DAYS = "2"; +const SUBJECT = "/CN=ado-proxy ephemeral interception CA"; + +function openssl(args: readonly string[], cwd: string): void { + try { + execFileSync("openssl", args as string[], { + cwd, + stdio: ["ignore", "ignore", "pipe"], + timeout: 60_000, + }); + } catch (error) { + const stderr = (error as { stderr?: Buffer }).stderr?.toString().trim(); + throw new CaError( + `openssl ${args[0]} failed${stderr === undefined || stderr === "" ? "" : `: ${stderr}`}`, + ); + } +} + +/** + * Generate a fresh CA and one leaf per protected host. + * + * All hosts are known at startup — the protected set is compiler-pinned and + * tiny — so leaves are minted eagerly. That keeps `openssl` off the request + * path entirely and means a broken toolchain fails at startup rather than on + * the first intercepted connection. + */ +export function mintCa( + directory: string, + hosts: readonly string[], +): CaMaterials { + mkdirSync(directory, { recursive: true, mode: 0o700 }); + + openssl( + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + DAYS, + "-subj", + SUBJECT, + "-keyout", + "ca.key", + "-out", + "ca.pem", + "-addext", + "basicConstraints=critical,CA:TRUE,pathlen:0", + "-addext", + "keyUsage=critical,keyCertSign,cRLSign", + ], + directory, + ); + + const leaves = new Map(); + for (const rawHost of hosts) { + const host = rawHost.toLowerCase(); + if (leaves.has(host)) continue; + if (!/^[a-z0-9.-]+$/.test(host)) { + // The protected set is compiler-owned, but this string ends up in an + // openssl config file; refuse anything that could break out of it. + throw new CaError(`refusing to mint a certificate for host ${rawHost}`); + } + + const keyFile = `${host}.key`; + const csrFile = `${host}.csr`; + const certFile = `${host}.pem`; + const extFile = `${host}.ext`; + + writeFileSync( + join(directory, extFile), + [ + "basicConstraints=CA:FALSE", + "keyUsage=critical,digitalSignature,keyEncipherment", + "extendedKeyUsage=serverAuth", + `subjectAltName=DNS:${host}`, + "", + ].join("\n"), + { mode: 0o600 }, + ); + + openssl( + [ + "req", + "-new", + "-newkey", + "rsa:2048", + "-nodes", + "-subj", + `/CN=${host}`, + "-keyout", + keyFile, + "-out", + csrFile, + ], + directory, + ); + + openssl( + [ + "x509", + "-req", + "-in", + csrFile, + "-CA", + "ca.pem", + "-CAkey", + "ca.key", + "-CAcreateserial", + "-days", + DAYS, + "-extfile", + extFile, + "-out", + certFile, + ], + directory, + ); + + leaves.set(host, { + key: readFileSync(join(directory, keyFile), "utf8"), + cert: readFileSync(join(directory, certFile), "utf8"), + }); + } + + return { + caCertPem: readFileSync(join(directory, "ca.pem"), "utf8"), + leaves, + }; +} + +/** + * Publish the CA certificate where AWF expects it. + * + * AWF pre-creates this path as a regular file before the sidecar starts, so a + * symlink cannot be swapped in between creation and write. Only the public + * certificate is ever written; the private key stays in the tmpfs directory. + */ +export function publishCaCertificate(path: string, caCertPem: string): void { + writeFileSync(path, caCertPem, { mode: 0o644 }); +} diff --git a/scripts/ado-script/src/ado-proxy/catalog-drift.test.ts b/scripts/ado-script/src/ado-proxy/catalog-drift.test.ts new file mode 100644 index 000000000..b2d7da3f5 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/catalog-drift.test.ts @@ -0,0 +1,142 @@ +/** + * Drift guard between the Rust `ado-proxy` catalog and the committed snapshot + * the bundle enforces. + * + * The compiler *emits* the policy document and this bundle *consumes* it, so + * the two must not diverge. Three mechanisms keep them aligned, and this file + * covers the second and third: + * + * 1. `ado-proxy-catalog.types.gen.ts` is generated from the Rust JSON Schema, + * so the bundle cannot compile against a stale *shape*; + * 2. `catalog.gen.json` is a committed snapshot of the catalog *data* — this + * test re-runs the Rust exporter and fails on any difference, so a + * Rust-side change to an operation, scope, response policy, or denial + * family forces a regeneration (`npm run codegen`) instead of silently + * diverging from what the sidecar enforces; + * 3. `schema_version` is embedded in the emitted policy document and + * re-checked by the sidecar at startup, so a stale mounted policy file + * fails closed rather than under-enforcing. + * + * Mirrors the existing gate-spec / `FACT_META` drift guard. + */ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import type { Catalog } from "../shared/ado-proxy-catalog.types.gen.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const snapshotPath = join(here, "catalog.gen.json"); +const manifestPath = join(here, "..", "..", "..", "..", "Cargo.toml"); + +/** + * `cargo run` may need to build the compiler from cold, which comfortably + * exceeds vitest's 5s default. Keep the subprocess and the test bounded by the + * same generous budget so a genuinely hung cargo still fails rather than + * stalling the suite. + */ +const CARGO_TIMEOUT_MS = 10 * 60 * 1000; + +function readSnapshot(): Catalog { + return JSON.parse(readFileSync(snapshotPath, "utf8")) as Catalog; +} + +/** + * Re-run the Rust exporter. Returns `undefined` when cargo is unavailable so + * the suite still runs in environments without a Rust toolchain; CI has cargo, + * so the guard is enforced where it matters. + */ +function exportFromRust(): Catalog | undefined { + try { + const stdout = execFileSync( + "cargo", + [ + "run", + "--quiet", + "--manifest-path", + manifestPath, + "--", + "export-ado-proxy-catalog", + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: CARGO_TIMEOUT_MS }, + ); + return JSON.parse(stdout) as Catalog; + } catch { + return undefined; + } +} + +describe("ado-proxy catalog drift guard", () => { + it( + "committed snapshot matches the Rust exporter", + () => { + const live = exportFromRust(); + if (!live) { + // No cargo on this machine; the shape assertions below still run. + return; + } + expect( + live, + "src/ado-proxy/catalog.gen.json is stale — run `npm run codegen`", + ).toEqual(readSnapshot()); + }, + CARGO_TIMEOUT_MS, + ); + + it("declares the schema version the sidecar pins against", () => { + const catalog = readSnapshot(); + expect(catalog.schema_version).toBe("ado-aw/ado-proxy-catalog/v1"); + }); + + it("keeps the runtime unreachable until the compiler wiring lands", () => { + // The bundle exists and is tested, but nothing emits its sidecar or policy + // document yet, so authors must not be told the capability is available. + expect(readSnapshot().runtime_available).toBe(false); + }); + + it("protects only Azure DevOps REST hosts", () => { + const { protected_hosts } = readSnapshot(); + expect(protected_hosts).toContain("dev.azure.com"); + // Package, artifact, and token hosts must stay on the normal Squid path + // and must never receive the injected bearer. + for (const denied of [ + "pkgs.dev.azure.com", + "artifacts.dev.azure.com", + "vstoken.dev.azure.com", + "vssps.dev.azure.com", + ]) { + expect(protected_hosts).not.toContain(denied); + } + }); + + it("exposes only read-shaped methods", () => { + for (const operation of readSnapshot().operations) { + expect(["GET", "OPTIONS"]).toContain(operation.method); + } + }); + + it("gives every operation a unique id", () => { + const ids = readSnapshot().operations.map((operation) => operation.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("keeps known credential-bearing families denied", () => { + const { denied_route_families } = readSnapshot(); + for (const required of [ + "/_apis/serviceendpoint", + "/_apis/distributedtask/variablegroups", + "/_apis/distributedtask/securefiles", + "/_git/", + ]) { + expect(denied_route_families).toContain(required); + } + }); + + it("exports an ordered API-version window", () => { + const { api_version_min, api_version_max } = readSnapshot(); + expect(api_version_min[0]).toBeLessThanOrEqual(api_version_max[0]); + }); +}); diff --git a/scripts/ado-script/src/ado-proxy/catalog.gen.json b/scripts/ado-script/src/ado-proxy/catalog.gen.json new file mode 100644 index 000000000..a34227ed2 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/catalog.gen.json @@ -0,0 +1,616 @@ +{ + "schema_version": "ado-aw/ado-proxy-catalog/v1", + "runtime_available": false, + "protected_hosts": [ + "dev.azure.com", + "app.vssps.visualstudio.com" + ], + "operations": [ + { + "id": "discovery.host-options", + "capability": "discovery", + "method": "OPTIONS", + "host": "current-organization", + "route": "/{org}/_apis", + "api_version": "absent", + "scope": "current-organization", + "response": "json", + "allowed_query": [ + "allHostTypes" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "discovery.area-options", + "capability": "discovery", + "method": "OPTIONS", + "host": "current-organization", + "route": "/{org}/_apis/{area}", + "api_version": "absent", + "scope": "current-organization", + "response": "json", + "allowed_query": [], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "discovery.resource-areas", + "capability": "discovery", + "method": "GET", + "host": "current-organization", + "route": "/{org}/_apis/resourceareas", + "api_version": "5.0..=7.2; preview allowed", + "scope": "filter-resource-areas", + "response": "filter-resource-areas", + "allowed_query": [], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "discovery.sps-resource-area", + "capability": "discovery", + "method": "GET", + "host": "sps-fallback", + "route": "/_apis/resourceareas/{areaId}", + "api_version": "5.0..=7.2; preview allowed", + "scope": "allowed-resource-area", + "response": "json", + "allowed_query": [], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "discovery.connection-data", + "capability": "discovery", + "method": "GET", + "host": "current-organization", + "route": "/{org}/_apis/connectiondata", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-organization", + "response": "json", + "allowed_query": [ + "connectOptions", + "lastChangeId", + "lastChangeId64" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "core.project-get", + "capability": "core", + "method": "GET", + "host": "current-organization", + "route": "/{org}/_apis/projects/{project}", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [ + "includeCapabilities", + "includeHistory" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "core.project-validation-probe", + "capability": "core", + "method": "GET", + "host": "current-organization", + "route": "/{org}/_apis/projects", + "api_version": "5.0..=7.2; preview allowed", + "scope": "filter-projects-to-current", + "response": "filter-projects", + "allowed_query": [ + "stateFilter", + "$top", + "$skip" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "repos.repository-get", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/git/repositories/{repository}", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-repository-path", + "response": "json", + "allowed_query": [ + "includeParent" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "repos.refs-list", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/git/repositories/{repository}/refs", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-repository-path", + "response": "json", + "allowed_query": [ + "filter", + "filterContains", + "includeLinks", + "includeStatuses", + "includeMyBranches", + "latestStatusesOnly", + "peelTags", + "$top", + "continuationToken" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "repos.items-list", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/git/repositories/{repository}/items", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-repository-path", + "response": "json", + "allowed_query": [ + "path", + "scopePath", + "recursionLevel", + "includeContentMetadata", + "latestProcessedChange", + "includeLinks", + "versionDescriptor.version", + "versionDescriptor.versionType", + "versionDescriptor.versionOptions" + ], + "denied_query": [ + "download", + "$format", + "zipForUnix" + ], + "max_response_bytes": 10485760 + }, + { + "id": "repos.commits-list", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/git/repositories/{repository}/commits", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-repository-path", + "response": "json", + "allowed_query": [ + "$top", + "$skip", + "searchCriteria" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "repos.commit-get", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/git/repositories/{repository}/commits/{commitId}", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-repository-path", + "response": "json", + "allowed_query": [ + "changeCount" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "repos.commit-changes", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/git/repositories/{repository}/commits/{commitId}/changes", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-repository-path", + "response": "json", + "allowed_query": [ + "top", + "skip" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "repos.pull-requests-list", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/git/repositories/{repository}/pullrequests", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-repository-path", + "response": "json", + "allowed_query": [ + "searchCriteria", + "$top", + "$skip", + "maxCommentLength" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "repos.pull-request-get", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/git/repositories/{repository}/pullrequests/{pullRequestId}", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-repository-path", + "response": "json", + "allowed_query": [ + "maxCommentLength", + "$top", + "$skip", + "includeCommits", + "includeWorkItemRefs" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "repos.pull-request-get-by-id", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/_apis/git/pullrequests/{pullRequestId}", + "api_version": "5.0..=7.2; preview allowed", + "scope": "response-current-repository", + "response": "validate-project-and-repository", + "allowed_query": [ + "maxCommentLength", + "includeCommits", + "includeWorkItemRefs" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "repos.pull-request-threads", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/git/repositories/{repository}/pullrequests/{pullRequestId}/threads", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-repository-path", + "response": "json", + "allowed_query": [ + "$top", + "$skip", + "iteration", + "baseIteration" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "repos.pull-request-iterations", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/git/repositories/{repository}/pullrequests/{pullRequestId}/iterations", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-repository-path", + "response": "json", + "allowed_query": [ + "includeCommits" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "repos.pull-request-reviewers", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/git/repositories/{repository}/pullrequests/{pullRequestId}/reviewers", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-repository-path", + "response": "json", + "allowed_query": [], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "repos.pull-request-work-items", + "capability": "repos", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/git/repositories/{repository}/pullrequests/{pullRequestId}/workitems", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-repository-path", + "response": "json", + "allowed_query": [], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "pipelines.definitions-list", + "capability": "pipelines", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/build/definitions", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [ + "name", + "repositoryId", + "repositoryType", + "$top", + "continuationToken", + "path" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "pipelines.definition-get", + "capability": "pipelines", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/build/definitions/{definitionId}", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [ + "revision", + "propertyFilters", + "includeLatestBuilds" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "pipelines.builds-list", + "capability": "pipelines", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/build/builds", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [ + "definitions", + "buildNumber", + "minTime", + "maxTime", + "reasonFilter", + "statusFilter", + "resultFilter", + "$top", + "continuationToken", + "queryOrder", + "branchName", + "buildIds", + "repositoryId", + "repositoryType" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "pipelines.build-get", + "capability": "pipelines", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/build/builds/{buildId}", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [ + "propertyFilters" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "pipelines.timeline-get", + "capability": "pipelines", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/build/builds/{buildId}/timeline", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [ + "changeId", + "planId" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "pipelines.pipeline-list", + "capability": "pipelines", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/pipelines", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [ + "orderBy", + "$top", + "continuationToken" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "pipelines.pipeline-get", + "capability": "pipelines", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/pipelines/{pipelineId}", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [ + "pipelineVersion" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "pipelines.runs-list", + "capability": "pipelines", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/pipelines/{pipelineId}/runs", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "pipelines.run-get", + "capability": "pipelines", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/pipelines/{pipelineId}/runs/{runId}", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "boards.work-item-get", + "capability": "boards", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/wit/workitems/{id}", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [ + "fields", + "asOf", + "$expand" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "boards.work-item-get-by-id", + "capability": "boards", + "method": "GET", + "host": "current-organization", + "route": "/{org}/_apis/wit/workitems/{id}", + "api_version": "5.0..=7.2; preview allowed", + "scope": "response-current-project", + "response": "validate-project", + "allowed_query": [ + "fields", + "asOf", + "$expand" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "boards.work-item-comments", + "capability": "boards", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/wit/workitems/{id}/comments", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [ + "$top", + "continuationToken", + "includeDeleted", + "expand", + "order" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "boards.work-item-updates", + "capability": "boards", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/wit/workitems/{id}/updates", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [ + "$top", + "$skip" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, + { + "id": "boards.work-item-revisions", + "capability": "boards", + "method": "GET", + "host": "current-organization", + "route": "/{org}/{project}/_apis/wit/workitems/{id}/revisions", + "api_version": "5.0..=7.2; preview allowed", + "scope": "current-project-path", + "response": "json", + "allowed_query": [ + "$top", + "$skip", + "$expand" + ], + "denied_query": [], + "max_response_bytes": 10485760 + } + ], + "denied_route_families": [ + "/_apis/accesscontrollists", + "/_apis/accesscontrolentries", + "/_apis/securitynamespaces", + "/_apis/permissions", + "/_apis/tokens", + "/_apis/tokenadmin", + "/_apis/delegatedauth", + "/_apis/oauth2", + "/_apis/serviceendpoint", + "/_apis/distributedtask/variablegroups", + "/_apis/distributedtask/securefiles", + "/_apis/build/builds/{buildId}/oauthtoken", + "/_apis/build/builds/{buildId}/artifacts", + "/_apis/build/builds/{buildId}/logs", + "/_apis/build/builds/{buildId}/attachments", + "/_apis/wit/wiql", + "/_apis/wit/workitemsbatch", + "/_apis/wit/workitems?ids=", + "/_apis/git/repositories/{repository}/itemsbatch", + "/_apis/git/repositories/{repository}/commitsbatch", + "/_apis/git/repositories/{repository}/blobs", + "/_apis/git/repositories/{repository}/trees", + "/_git/", + "/_odata/", + "/_apis/search/", + "/_apis/customerintelligence/events" + ], + "api_version_min": [ + 5, + 0 + ], + "api_version_max": [ + 7, + 2 + ] +} \ No newline at end of file diff --git a/scripts/ado-script/src/ado-proxy/catalog.test.ts b/scripts/ado-script/src/ado-proxy/catalog.test.ts new file mode 100644 index 000000000..8d51307ba --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/catalog.test.ts @@ -0,0 +1,115 @@ +/** + * Tests for the catalog helpers the enforcement path depends on. + * + * The catalog data itself is guarded by `catalog-drift.test.ts`; these cover + * the lookup semantics layered over it, where a subtle mistake (a suffix match + * on a host, say) would hand the injected bearer to the wrong destination. + */ +import { describe, expect, it } from "vitest"; + +import { + API_VERSION_MAX, + API_VERSION_MIN, + CATALOG_SCHEMA_VERSION, + DENIED_ROUTE_FAMILIES, + isProtectedHost, + OPERATIONS, + operationsFor, +} from "./catalog.js"; + +describe("isProtectedHost", () => { + it("matches the exact protected hosts, case-insensitively", () => { + expect(isProtectedHost("dev.azure.com")).toBe(true); + expect(isProtectedHost("DEV.AZURE.COM")).toBe(true); + expect(isProtectedHost(" dev.azure.com ")).toBe(true); + expect(isProtectedHost("app.vssps.visualstudio.com")).toBe(true); + }); + + it("does not match look-alike hosts by suffix or prefix", () => { + // A suffix match here would mean an attacker-controlled domain got the + // injected bearer; a prefix match would leak it to a sibling service. + expect(isProtectedHost("dev.azure.com.evil.test")).toBe(false); + expect(isProtectedHost("notdev.azure.com")).toBe(false); + expect(isProtectedHost("evil.test")).toBe(false); + }); + + it("canonicalizes trailing-dot FQDNs and CONNECT host:port targets", () => { + // `dev.azure.com.` is an absolute FQDN for the *same* origin. Treating it + // as unprotected would route it down the plain byte-tunnel path, skipping + // TLS termination and catalog enforcement entirely. + expect(isProtectedHost("dev.azure.com.")).toBe(true); + expect(isProtectedHost("dev.azure.com..")).toBe(true); + expect(isProtectedHost("DEV.AZURE.COM.")).toBe(true); + // CONNECT targets always carry a port. + expect(isProtectedHost("dev.azure.com:443")).toBe(true); + expect(isProtectedHost("dev.azure.com.:443")).toBe(true); + // Canonicalization must not create a match that isn't there. + expect(isProtectedHost("dev.azure.com.evil.test.")).toBe(false); + expect(isProtectedHost("")).toBe(false); + expect(isProtectedHost(".")).toBe(false); + }); + + it("never protects IP literals", () => { + // Raw-IP destinations bypass domain policy by definition; they must take + // the unprotected path and are separately denied by Squid. + expect(isProtectedHost("13.107.42.20")).toBe(false); + expect(isProtectedHost("[::1]")).toBe(false); + expect(isProtectedHost("[::1]:443")).toBe(false); + }); + + it("leaves package, artifact, and token hosts unprotected", () => { + // These stay on the normal Squid path and must never be TLS-terminated or + // receive the bearer — package restore has its own feed credentials. + for (const host of [ + "pkgs.dev.azure.com", + "artifacts.dev.azure.com", + "vstoken.dev.azure.com", + "vssps.dev.azure.com", + ]) { + expect(isProtectedHost(host)).toBe(false); + } + }); +}); + +describe("operationsFor", () => { + it("returns only operations in the enabled capability set", () => { + const repos = operationsFor(["repos"]); + expect(repos.length).toBeGreaterThan(0); + expect(repos.every((operation) => operation.capability === "repos")).toBe( + true, + ); + }); + + it("returns nothing when no capability is enabled", () => { + expect(operationsFor([])).toHaveLength(0); + }); + + it("is additive across capabilities", () => { + const discovery = operationsFor(["discovery"]).length; + const repos = operationsFor(["repos"]).length; + expect(operationsFor(["discovery", "repos"])).toHaveLength( + discovery + repos, + ); + }); +}); + +describe("catalog surface", () => { + it("exposes the schema version the policy document pins against", () => { + expect(CATALOG_SCHEMA_VERSION).toBe("ado-aw/ado-proxy-catalog/v1"); + }); + + it("catalogues only read-shaped methods", () => { + for (const operation of OPERATIONS) { + expect(["GET", "OPTIONS"]).toContain(operation.method); + } + }); + + it("keeps credential-bearing families denied", () => { + expect(DENIED_ROUTE_FAMILIES).toContain("/_apis/serviceendpoint"); + expect(DENIED_ROUTE_FAMILIES).toContain("/_git/"); + }); + + it("exposes an ordered API-version window", () => { + expect(API_VERSION_MIN[0]).toBeLessThanOrEqual(API_VERSION_MAX[0]); + }); +}); diff --git a/scripts/ado-script/src/ado-proxy/catalog.ts b/scripts/ado-script/src/ado-proxy/catalog.ts new file mode 100644 index 000000000..c12de198b --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/catalog.ts @@ -0,0 +1,102 @@ +/** + * The `ado-proxy` operation catalog, as the bundle sees it. + * + * The catalog is **authored in Rust** (`src/ado_proxy/catalog.rs`) and exported + * here by `npm run codegen` as `catalog.gen.json`, with its TypeScript shape + * generated into `../shared/ado-proxy-catalog.types.gen.ts`. This module only + * loads that snapshot and exposes helpers over it — it never restates policy, + * so the compiler and the sidecar cannot disagree about what is allowed. + * + * A drift test (`catalog-drift.test.ts`) re-runs the Rust exporter and fails if + * the snapshot is stale. + */ +import catalogJson from "./catalog.gen.json" with { type: "json" }; + +import type { + Capability, + Catalog, + Operation, +} from "../shared/ado-proxy-catalog.types.gen.js"; + +/** The committed catalog snapshot. */ +export const CATALOG: Catalog = catalogJson as Catalog; + +/** + * Catalog version this bundle enforces. + * + * Embedded by the compiler into the emitted policy document and re-checked at + * startup, so a stale policy file fails closed. + */ +export const CATALOG_SCHEMA_VERSION: string = CATALOG.schema_version; + +/** Hosts whose traffic is TLS-terminated and policy-checked. */ +export const PROTECTED_HOSTS: readonly string[] = CATALOG.protected_hosts; + +/** Route families that are always denied, regardless of capability. */ +export const DENIED_ROUTE_FAMILIES: readonly string[] = + CATALOG.denied_route_families; + +/** Every catalogued operation. */ +export const OPERATIONS: readonly Operation[] = CATALOG.operations; + +/** Operations reachable with the given capability set. */ +export function operationsFor( + capabilities: readonly Capability[], +): readonly Operation[] { + const enabled = new Set(capabilities); + return OPERATIONS.filter((operation) => enabled.has(operation.capability)); +} + +/** + * Canonicalize a host for protection checks. + * + * Handles the forms a CONNECT target or `Host` header can legitimately take + * for the *same* origin: + * + * - surrounding whitespace and mixed case; + * - a `host:port` suffix (CONNECT targets always carry one); + * - a trailing DNS root dot (`dev.azure.com.` is an absolute FQDN for the + * same host); + * - bracketed IPv6 literals, which are never protected but must not be + * mangled into something that accidentally matches. + */ +export function canonicalizeHost(host: string): string { + let value = host.trim().toLowerCase(); + + if (value.startsWith("[")) { + // IPv6 literal: keep the bracketed form, drop only a trailing :port. + const closing = value.indexOf("]"); + if (closing !== -1) value = value.slice(0, closing + 1); + return value; + } + + const colon = value.lastIndexOf(":"); + if (colon !== -1 && /^\d+$/.test(value.slice(colon + 1))) { + value = value.slice(0, colon); + } + + // Strip the DNS root dot. Repeated so `host..` cannot survive as a variant + // that fails the equality check while still resolving. + while (value.endsWith(".")) value = value.slice(0, -1); + + return value; +} + +/** + * True when `host` is one this proxy must terminate and police. + * + * Compared as an exact match against the canonicalized protected set — never + * by suffix, so a look-alike such as `dev.azure.com.evil.test` is not + * protected (and therefore never receives the injected bearer). + */ +export function isProtectedHost(host: string): boolean { + const normalized = canonicalizeHost(host); + if (normalized === "") return false; + return PROTECTED_HOSTS.some( + (protectedHost) => canonicalizeHost(protectedHost) === normalized, + ); +} + +/** Inclusive `[major, minor]` bounds of the accepted REST API version. */ +export const API_VERSION_MIN: readonly [number, number] = CATALOG.api_version_min; +export const API_VERSION_MAX: readonly [number, number] = CATALOG.api_version_max; diff --git a/scripts/ado-script/src/ado-proxy/config.test.ts b/scripts/ado-script/src/ado-proxy/config.test.ts new file mode 100644 index 000000000..c27b40f01 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/config.test.ts @@ -0,0 +1,205 @@ +/** + * Configuration and policy-validation tests for the `ado-proxy` bundle. + * + * These cover the fail-closed startup contract: the proxy must refuse to serve + * rather than start with a policy it cannot fully honour, because a running + * proxy with a bad policy is an open tunnel to the protected hosts. + */ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { beforeEach, describe, expect, it } from "vitest"; + +import { CATALOG_SCHEMA_VERSION } from "./catalog.js"; +import { ConfigError, loadConfig, parsePolicy } from "./config.js"; + +const VALID_POLICY = { + catalog_version: CATALOG_SCHEMA_VERSION, + organization: "contoso", + project: "Playground", + project_id: "01234567-89ab-cdef-0123-456789abcdef", + repository: "app", + capabilities: ["discovery", "repos"], + protected_hosts: ["dev.azure.com", "app.vssps.visualstudio.com"], + allowed_resource_areas: [], +}; + +function policyJson(overrides: Record = {}): string { + return JSON.stringify({ ...VALID_POLICY, ...overrides }); +} + +/** Env vars the loader reads; cleared so host state cannot leak into a test. */ +const PROXY_ENV_KEYS = [ + "ADO_PROXY_POLICY_FILE", + "ADO_PROXY_TOKEN_FILE", + "AWF_POLICY_PROXY_LISTEN_ADDRESS", + "AWF_POLICY_PROXY_LISTEN_PORT", + "AWF_POLICY_PROXY_UPSTREAM_PROXY", + "AWF_POLICY_PROXY_PUBLIC_CA_PATH", + "AWF_POLICY_PROXY_LOG_DIR", +]; + +beforeEach(() => { + for (const key of PROXY_ENV_KEYS) delete process.env[key]; +}); + +describe("parsePolicy", () => { + it("accepts a well-formed policy", () => { + const policy = parsePolicy(policyJson()); + expect(policy.organization).toBe("contoso"); + expect(policy.project).toBe("Playground"); + expect(policy.capabilities).toEqual(["discovery", "repos"]); + }); + + it("rejects a catalog_version the bundle does not implement", () => { + // The central anti-divergence guarantee: a stale mounted policy must fail + // closed rather than under-enforce against a newer catalog. + expect(() => + parsePolicy(policyJson({ catalog_version: "ado-aw/ado-proxy-catalog/v0" })), + ).toThrow(/does not match/); + }); + + it("rejects an unknown capability", () => { + expect(() => + parsePolicy(policyJson({ capabilities: ["discovery", "everything"] })), + ).toThrow(/unknown capability/); + }); + + it("rejects an empty protected-host set", () => { + // With no protected hosts the proxy would tunnel everything unchecked. + expect(() => parsePolicy(policyJson({ protected_hosts: [] }))).toThrow( + /protected_hosts must not be empty/, + ); + }); + + it("rejects a protected-host set that omits a catalogued host", () => { + // A catalogued host missing from the policy would take the byte-tunnel + // path to Squid instead of being policed — the one bypass this proxy + // exists to prevent. + expect(() => + parsePolicy(policyJson({ protected_hosts: ["dev.azure.com"] })), + ).toThrow(/omits the catalogued host app\.vssps\.visualstudio\.com/); + }); + + it("rejects an unknown key rather than ignoring it", () => { + // An unrecognized key means the compiler emitted a constraint this bundle + // does not implement; ignoring it would silently under-enforce. + expect(() => parsePolicy(policyJson({ max_requests_per_minute: 10 }))).toThrow( + /unknown key/, + ); + }); + + it.each([ + ["organization", { organization: "" }], + ["project", { project: "" }], + ])("rejects a missing %s scope", (_label, overrides) => { + expect(() => parsePolicy(policyJson(overrides))).toThrow(ConfigError); + }); + + it("rejects malformed JSON and non-object documents", () => { + expect(() => parsePolicy("{not json")).toThrow(/not valid JSON/); + expect(() => parsePolicy("[]")).toThrow(/must be a JSON object/); + expect(() => parsePolicy("null")).toThrow(/must be a JSON object/); + }); + + it("treats optional scope ids as absent rather than empty", () => { + const policy = parsePolicy( + policyJson({ repository: undefined, repository_id: undefined }), + ); + expect(policy.repository).toBeUndefined(); + expect(policy.repository_id).toBeUndefined(); + }); +}); + +describe("loadConfig", () => { + function writePolicy(): string { + const dir = mkdtempSync(join(tmpdir(), "ado-proxy-config-")); + const path = join(dir, "policy.json"); + writeFileSync(path, policyJson()); + return path; + } + + const baseArgs = (policyFile: string): string[] => [ + "--policy-file", + policyFile, + "--token-file", + "/private/token", + "--public-ca-file", + "/ca/ca.pem", + "--upstream-proxy", + "http://squid-proxy:3128", + ]; + + it("resolves flags and applies defaults", () => { + const config = loadConfig(baseArgs(writePolicy())); + expect(config.listenAddress).toBe("0.0.0.0"); + expect(config.listenPort).toBe(11080); + expect(config.upstreamProxy).toBe("http://squid-proxy:3128"); + expect(config.policy.organization).toBe("contoso"); + }); + + it("accepts --flag=value form", () => { + const policyFile = writePolicy(); + const config = loadConfig([ + `--policy-file=${policyFile}`, + "--token-file=/private/token", + "--public-ca-file=/ca/ca.pem", + "--upstream-proxy=http://squid-proxy:3128", + "--listen-port=12000", + ]); + expect(config.listenPort).toBe(12000); + }); + + it("falls back to the AWF environment contract", () => { + const policyFile = writePolicy(); + process.env.ADO_PROXY_POLICY_FILE = policyFile; + process.env.ADO_PROXY_TOKEN_FILE = "/private/token"; + process.env.AWF_POLICY_PROXY_PUBLIC_CA_PATH = "/ca/ca.pem"; + process.env.AWF_POLICY_PROXY_UPSTREAM_PROXY = "http://squid-proxy:3128"; + process.env.AWF_POLICY_PROXY_LISTEN_PORT = "13000"; + + const config = loadConfig([]); + expect(config.listenPort).toBe(13000); + expect(config.tokenFile).toBe("/private/token"); + }); + + it("requires an upstream proxy", () => { + // Squid is the only route out; without it there is no egress path at all, + // and silently defaulting would risk a direct-internet fallback. + const policyFile = writePolicy(); + expect(() => + loadConfig([ + "--policy-file", + policyFile, + "--token-file", + "/private/token", + "--public-ca-file", + "/ca/ca.pem", + ]), + ).toThrow(/--upstream-proxy/); + }); + + it("rejects an unusable listen port", () => { + const policyFile = writePolicy(); + for (const port of ["0", "70000", "not-a-port"]) { + expect(() => + loadConfig([...baseArgs(policyFile), "--listen-port", port]), + ).toThrow(/listen-port/); + } + }); + + it("reports an unreadable policy file", () => { + expect(() => + loadConfig(baseArgs(join(tmpdir(), "ado-proxy-does-not-exist.json"))), + ).toThrow(/cannot read policy file/); + }); + + it("never carries a credential in its resolved configuration", () => { + // The bearer lives in a private file the trusted host task rotates; only + // its *path* may appear in configuration. + const config = loadConfig(baseArgs(writePolicy())); + expect(JSON.stringify(config)).not.toContain("Bearer"); + expect(config.tokenFile).toBe("/private/token"); + }); +}); diff --git a/scripts/ado-script/src/ado-proxy/config.ts b/scripts/ado-script/src/ado-proxy/config.ts new file mode 100644 index 000000000..75d31469d --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/config.ts @@ -0,0 +1,282 @@ +/** + * Runtime configuration for the `ado-proxy` sidecar. + * + * Everything here is non-secret. The bearer never appears in argv, the + * environment, or this module: it lives in a private file the trusted host task + * rotates, read on demand by `token.ts`. + * + * Sources, in precedence order: explicit CLI flags, then the generic + * `AWF_POLICY_PROXY_*` environment contract AWF publishes for any policy-proxy + * sidecar, then defaults. Invalid or missing required values are fatal — a + * half-configured proxy would silently downgrade to an open tunnel. + */ +import { readFileSync } from "node:fs"; + +import type { Capability } from "../shared/ado-proxy-catalog.types.gen.js"; +import { CATALOG_SCHEMA_VERSION, PROTECTED_HOSTS } from "./catalog.js"; + +/** Resolved, validated proxy configuration. */ +export interface ProxyConfig { + /** Address the agent's `HTTP(S)_PROXY` points at. */ + readonly listenAddress: string; + /** Port the agent's `HTTP(S)_PROXY` points at. */ + readonly listenPort: number; + /** Squid URL. The proxy's only route out; there is no direct-internet path. */ + readonly upstreamProxy: string; + /** Private file the trusted host task rotates the ADO bearer into. */ + readonly tokenFile: string; + /** Pre-created file the public interception certificate is written into. */ + readonly publicCaFile: string; + /** Directory for the sanitized JSONL decision log, when configured. */ + readonly logDir?: string; + /** The scope and capability policy this proxy enforces. */ + readonly policy: ProxyPolicy; +} + +/** The compiler-emitted policy document. */ +export interface ProxyPolicy { + /** + * Catalog version this document was generated against. + * + * Re-checked against the version compiled into this bundle at startup, so a + * stale mounted policy file fails closed instead of under-enforcing. + */ + readonly catalog_version: string; + /** Azure DevOps organization the agent is scoped to. */ + readonly organization: string; + /** Project name the agent is scoped to. */ + readonly project: string; + /** Project id (GUID), when the compiler could resolve one. */ + readonly project_id?: string; + /** Repository name the agent is scoped to. */ + readonly repository?: string; + /** Repository id (GUID), when the compiler could resolve one. */ + readonly repository_id?: string; + /** Enabled capability groups; an operation outside these is denied. */ + readonly capabilities: readonly Capability[]; + /** Hosts whose traffic is TLS-terminated and policy-checked. */ + readonly protected_hosts: readonly string[]; + /** Resource-area ids the SPS fallback discovery route may resolve. */ + readonly allowed_resource_areas: readonly string[]; +} + +export class ConfigError extends Error {} + +function fail(message: string): never { + throw new ConfigError(message); +} + +/** Read a flag from argv (`--name value` or `--name=value`), else the env. */ +function readOption( + argv: readonly string[], + flag: string, + envName: string, +): string | undefined { + const prefixed = `--${flag}=`; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === undefined) continue; + if (arg === `--${flag}`) return argv[index + 1]; + if (arg.startsWith(prefixed)) return arg.slice(prefixed.length); + } + const fromEnv = process.env[envName]; + return fromEnv === undefined || fromEnv === "" ? undefined : fromEnv; +} + +function requireOption( + argv: readonly string[], + flag: string, + envName: string, +): string { + const value = readOption(argv, flag, envName); + if (value === undefined || value.trim() === "") { + fail(`missing required option --${flag} (or ${envName})`); + } + return value; +} + +function parsePort(raw: string, label: string): number { + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + fail(`${label} must be an integer port in 1-65535, got ${JSON.stringify(raw)}`); + } + return port; +} + +/** Guard against a policy document that is not a JSON object. */ +function asRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + fail(`${label} must be a JSON object`); + } + return value as Record; +} + +function requireString( + source: Record, + key: string, +): string { + const value = source[key]; + if (typeof value !== "string" || value.trim() === "") { + fail(`policy.${key} must be a non-empty string`); + } + return value; +} + +function optionalString( + source: Record, + key: string, +): string | undefined { + const value = source[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "string" || value.trim() === "") { + fail(`policy.${key} must be a non-empty string when present`); + } + return value; +} + +function requireStringArray( + source: Record, + key: string, +): string[] { + const value = source[key]; + if (!Array.isArray(value)) fail(`policy.${key} must be an array`); + return value.map((entry, index) => { + if (typeof entry !== "string" || entry.trim() === "") { + fail(`policy.${key}[${index}] must be a non-empty string`); + } + return entry; + }); +} + +const KNOWN_CAPABILITIES: readonly Capability[] = [ + "discovery", + "core", + "repos", + "pipelines", + "boards", +]; + +/** + * Every key the policy document may carry. + * + * An unrecognized key means the compiler emitted a constraint this bundle does + * not implement. Ignoring it would silently under-enforce, so it is fatal. + */ +const KNOWN_POLICY_KEYS: readonly string[] = [ + "catalog_version", + "organization", + "project", + "project_id", + "repository", + "repository_id", + "capabilities", + "protected_hosts", + "allowed_resource_areas", +]; + +/** + * Parse and validate the compiler-emitted policy document. + * + * Fails closed on: a non-object document, a missing or mismatched + * `catalog_version`, an unknown key, an unknown capability, a protected-host + * set that does not cover the catalog, or a missing required scope. Any of + * those would otherwise let the proxy enforce a different policy than the + * compiler intended. + */ +export function parsePolicy(raw: string): ProxyPolicy { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + fail(`policy file is not valid JSON: ${(error as Error).message}`); + } + const document = asRecord(parsed, "policy"); + + for (const key of Object.keys(document)) { + if (!KNOWN_POLICY_KEYS.includes(key)) { + fail( + `policy contains unknown key ${JSON.stringify(key)}. Refusing to start: ` + + "an unrecognized constraint would be silently ignored.", + ); + } + } + + const catalogVersion = requireString(document, "catalog_version"); + if (catalogVersion !== CATALOG_SCHEMA_VERSION) { + fail( + `policy catalog_version ${JSON.stringify(catalogVersion)} does not match ` + + `this bundle's ${JSON.stringify(CATALOG_SCHEMA_VERSION)}. Refusing to ` + + "start: a stale policy document would under-enforce.", + ); + } + + const capabilities = requireStringArray(document, "capabilities"); + for (const capability of capabilities) { + if (!KNOWN_CAPABILITIES.includes(capability as Capability)) { + fail(`policy.capabilities contains an unknown capability: ${capability}`); + } + } + + const protectedHosts = requireStringArray(document, "protected_hosts"); + if (protectedHosts.length === 0) { + fail("policy.protected_hosts must not be empty"); + } + for (const catalogued of PROTECTED_HOSTS) { + // A catalogued host missing here would be byte-tunnelled to Squid instead + // of policed, which is the one failure mode this proxy cannot tolerate. + if (!protectedHosts.some((host) => host.toLowerCase() === catalogued.toLowerCase())) { + fail( + `policy.protected_hosts omits the catalogued host ${catalogued}; ` + + "it would bypass policy enforcement.", + ); + } + } + + return { + catalog_version: catalogVersion, + organization: requireString(document, "organization"), + project: requireString(document, "project"), + project_id: optionalString(document, "project_id"), + repository: optionalString(document, "repository"), + repository_id: optionalString(document, "repository_id"), + capabilities: capabilities as Capability[], + protected_hosts: protectedHosts, + allowed_resource_areas: Array.isArray(document.allowed_resource_areas) + ? requireStringArray(document, "allowed_resource_areas") + : [], + }; +} + +/** Resolve the full runtime configuration from argv and the environment. */ +export function loadConfig(argv: readonly string[]): ProxyConfig { + const policyFile = requireOption(argv, "policy-file", "ADO_PROXY_POLICY_FILE"); + let policyRaw: string; + try { + policyRaw = readFileSync(policyFile, "utf8"); + } catch (error) { + fail(`cannot read policy file ${policyFile}: ${(error as Error).message}`); + } + + const listenPortRaw = + readOption(argv, "listen-port", "AWF_POLICY_PROXY_LISTEN_PORT") ?? "11080"; + + return { + listenAddress: + readOption(argv, "listen-address", "AWF_POLICY_PROXY_LISTEN_ADDRESS") ?? + "0.0.0.0", + listenPort: parsePort(listenPortRaw, "--listen-port"), + upstreamProxy: requireOption( + argv, + "upstream-proxy", + "AWF_POLICY_PROXY_UPSTREAM_PROXY", + ), + tokenFile: requireOption(argv, "token-file", "ADO_PROXY_TOKEN_FILE"), + publicCaFile: requireOption( + argv, + "public-ca-file", + "AWF_POLICY_PROXY_PUBLIC_CA_PATH", + ), + logDir: readOption(argv, "log-dir", "AWF_POLICY_PROXY_LOG_DIR"), + policy: parsePolicy(policyRaw), + }; +} diff --git a/scripts/ado-script/src/ado-proxy/headers.test.ts b/scripts/ado-script/src/ado-proxy/headers.test.ts new file mode 100644 index 000000000..2f761991f --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/headers.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { sanitizeRequestHeaders, sanitizeResponseHeaders } from "./headers.js"; + +describe("sanitizeRequestHeaders", () => { + it("strips every client-supplied credential", () => { + const { headers, strippedCredentials } = sanitizeRequestHeaders( + { + authorization: "Basic OnNlbnRpbmVs", + "proxy-authorization": "Basic abc", + cookie: "UserAuthentication=x", + }, + "dev.azure.com", + ); + // The injected bearer is applied by the caller *after* the allow decision; + // nothing the client sent may influence the upstream identity. + expect(headers.authorization).toBeUndefined(); + expect(headers.cookie).toBeUndefined(); + expect(strippedCredentials).toEqual( + expect.arrayContaining(["authorization", "proxy-authorization", "cookie"]), + ); + }); + + it("drops headers that could change what the upstream believes the request is", () => { + const { headers } = sanitizeRequestHeaders( + { + "x-http-method-override": "POST", + "x-original-url": "/other/_apis/serviceendpoint", + "x-forwarded-host": "evil.test", + "transfer-encoding": "chunked", + forwarded: "for=1.2.3.4", + }, + "dev.azure.com", + ); + expect(Object.keys(headers).sort()).toEqual([ + "accept-encoding", + "connection", + "host", + "x-tfs-fedauthredirect", + ]); + }); + + it("forwards the negotiation and correlation headers Azure DevOps needs", () => { + const { headers } = sanitizeRequestHeaders( + { + accept: "application/json;api-version=7.1", + "user-agent": "azure-devops-cli", + "x-ms-continuationtoken": "abc", + "content-type": "application/json", + }, + "dev.azure.com", + ); + expect(headers.accept).toBe("application/json;api-version=7.1"); + expect(headers["user-agent"]).toBe("azure-devops-cli"); + expect(headers["x-ms-continuationtoken"]).toBe("abc"); + }); + + it("always suppresses the federated-auth redirect", () => { + // Without this Azure DevOps answers an auth failure with a 203 sign-in + // page, which clients surface as unparseable HTML rather than a 401. + const { headers } = sanitizeRequestHeaders( + { "x-tfs-fedauthredirect": "Auto" }, + "dev.azure.com", + ); + expect(headers["x-tfs-fedauthredirect"]).toBe("Suppress"); + }); + + it("pins the Host header to the intercepted host", () => { + const { headers } = sanitizeRequestHeaders({ host: "evil.test" }, "dev.azure.com"); + expect(headers.host).toBe("dev.azure.com"); + }); + + it("requests identity encoding", () => { + // Response filtering and the byte budget both operate on the plain body. + const { headers } = sanitizeRequestHeaders({ "accept-encoding": "gzip" }, "dev.azure.com"); + expect(headers["accept-encoding"]).toBe("identity"); + }); + + it("takes only the first value of a repeated header", () => { + const { headers } = sanitizeRequestHeaders( + { accept: ["application/json;api-version=7.1", "application/json;api-version=1.0"] }, + "dev.azure.com", + ); + expect(headers.accept).toBe("application/json;api-version=7.1"); + }); +}); + +describe("sanitizeResponseHeaders", () => { + it("keeps only the safe response headers", () => { + const headers = sanitizeResponseHeaders({ + "content-type": "application/json", + "x-ms-continuationtoken": "next", + "set-cookie": ["UserAuthentication=x"], + "www-authenticate": "Bearer realm=...", + location: "https://artifacts.example/signed?sig=abc", + }); + // `set-cookie` and `www-authenticate` would hand the agent session material + // or provoke an interactive login; `location` is how a signed URL escapes. + expect(headers).toEqual({ + "content-type": "application/json", + "x-ms-continuationtoken": "next", + }); + }); +}); diff --git a/scripts/ado-script/src/ado-proxy/headers.ts b/scripts/ado-script/src/ado-proxy/headers.ts new file mode 100644 index 000000000..4334cafdf --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/headers.ts @@ -0,0 +1,144 @@ +/** + * Header handling for protected (TLS-terminated) requests. + * + * Two jobs, both fail-closed: + * + * 1. **Strip every client-supplied credential.** The agent may set + * `Authorization`, a sentinel PAT, cookies, or an auth-like proxy header; + * none of it may influence the upstream call. The proxy's injected bearer + * is the only credential that ever reaches Azure DevOps. + * 2. **Forward only known-safe headers.** An allowlist rather than a denylist, + * so a header nobody thought about (`X-HTTP-Method-Override`, + * `X-Original-URL`, a smuggled `Transfer-Encoding`) cannot change what the + * upstream believes the request is. + */ + +/** + * Request headers forwarded upstream, lowercased. + * + * Deliberately small. Anything Azure DevOps genuinely needs for content + * negotiation, correlation, or paging is here; everything else is dropped + * because the request the policy authorized must be the request that is sent. + */ +const FORWARDED_REQUEST_HEADERS: ReadonlySet = new Set([ + // Content negotiation. `accept` also carries the api-version parameter that + // `resolveApiVersion` validates, so it must survive intact. + "accept", + "accept-language", + "content-type", + // Client identification, useful in upstream diagnostics and harmless. + "user-agent", + // Azure DevOps correlation/session headers. Dropping these degrades server + // -side tracing and makes some SDK paths chattier, but they carry no + // authority. + "x-tfs-session", + "x-vss-e2eid", + "x-vss-usersessionid", + // Paging. Without this a continued list restarts from the beginning. + "x-ms-continuationtoken", +]); + +/** + * Response headers returned to the client, lowercased. + * + * Also an allowlist: upstream `set-cookie`, `www-authenticate`, and redirect + * `location` headers must never reach the agent. The first two would hand it + * session material or provoke an interactive login; the third is how a signed + * artifact URL escapes. + */ +const FORWARDED_RESPONSE_HEADERS: ReadonlySet = new Set([ + "content-type", + "x-ms-continuationtoken", + "x-vss-e2eid", + "retry-after", +]); + +/** + * Headers whose presence is logged as a stripped credential. + * + * Only used for observability — everything outside the allowlist is dropped + * regardless. Naming these lets the audit stream distinguish "the agent tried + * to supply its own credential" from ordinary header noise. + */ +const CREDENTIAL_HEADERS: readonly string[] = [ + "authorization", + "proxy-authorization", + "cookie", + "cookie2", + "x-tfs-fedauthredirect", + "www-authenticate", +]; + +/** Result of sanitizing a client request's headers. */ +export interface SanitizedHeaders { + /** Headers to send upstream, already including the protocol headers. */ + readonly headers: Readonly>; + /** Names of credential-bearing headers the client supplied, for the log. */ + readonly strippedCredentials: readonly string[]; +} + +function firstValue(value: string | string[] | undefined): string | undefined { + if (value === undefined) return undefined; + // Node folds most repeated headers into one comma-joined string, but not + // `set-cookie`. Take the first: a header repeated with different values is + // exactly the ambiguity an upstream might resolve differently than we do. + return Array.isArray(value) ? value[0] : value; +} + +/** + * Build the upstream header set for an authorized request. + * + * The bearer is applied by the caller *after* the allow decision; this function + * never sees it, so no code path can accidentally emit it on a denial. + */ +export function sanitizeRequestHeaders( + incoming: Readonly>, + host: string, +): SanitizedHeaders { + const headers: Record = {}; + const strippedCredentials: string[] = []; + + for (const [rawName, rawValue] of Object.entries(incoming)) { + const name = rawName.toLowerCase(); + if (CREDENTIAL_HEADERS.includes(name)) { + strippedCredentials.push(name); + continue; + } + if (!FORWARDED_REQUEST_HEADERS.has(name)) continue; + const value = firstValue(rawValue); + if (value !== undefined) headers[name] = value; + } + + headers.host = host; + // Without this Azure DevOps answers an unauthenticated or under-privileged + // request with a 203 and a sign-in page instead of a 401, which clients + // surface as unparseable HTML rather than an auth failure. + headers["x-tfs-fedauthredirect"] = "Suppress"; + // Identity encoding keeps response filtering and the byte budget honest; the + // hop to the agent is loopback-adjacent, so the saving is not worth the + // decompression bomb surface. + headers["accept-encoding"] = "identity"; + headers.connection = "close"; + + return { headers, strippedCredentials }; +} + +/** Filter an upstream response's headers down to the safe set. */ +export function sanitizeResponseHeaders( + incoming: Readonly>, +): Record { + const headers: Record = {}; + for (const [rawName, rawValue] of Object.entries(incoming)) { + const name = rawName.toLowerCase(); + if (!FORWARDED_RESPONSE_HEADERS.has(name)) continue; + const value = firstValue(rawValue); + if (value !== undefined) headers[name] = value; + } + return headers; +} + +export const INTERNAL = { + FORWARDED_REQUEST_HEADERS, + FORWARDED_RESPONSE_HEADERS, + CREDENTIAL_HEADERS, +}; diff --git a/scripts/ado-script/src/ado-proxy/index.ts b/scripts/ado-script/src/ado-proxy/index.ts new file mode 100644 index 000000000..35d1fef8d --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/index.ts @@ -0,0 +1,131 @@ +/** + * `ado-proxy` — the credential-isolated Azure DevOps policy proxy. + * + * AWF runs this bundle as a managed sidecar on the internal `awf-net`, points + * the agent's `HTTP(S)_PROXY` at it, and denies the protected Azure DevOps + * hosts to the agent at Squid. That makes this process the only path from the + * agent to those hosts. + * + * Two request paths, and only two: + * + * - **Non-protected destination** — CONNECT through Squid and byte-tunnel in + * both directions. No TLS termination, no parsing, no header changes, so + * package feeds and every other allowed host behave exactly as they do + * without the sidecar. + * - **Protected destination** — terminate TLS with an ephemeral CA, evaluate + * the request against the versioned catalog, strip every client-supplied + * credential, and inject the current bearer *only* after a complete allow + * decision. + * + * The bearer is never in argv or the environment: it is read from a private + * file the trusted host task rotates. Only the *public* interception + * certificate is ever written out. + * + * Unlike the other `ado-script` bundles, which are short-lived pipeline steps, + * this one is a long-running server: it starts before the agent and is torn + * down by AWF when the agent exits. + */ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { CaError, mintCa, publishCaCertificate } from "./ca.js"; +import { ConfigError, loadConfig, type ProxyConfig } from "./config.js"; +import { DecisionLog } from "./log.js"; +import { createProxyServer } from "./server.js"; +import { TokenSource } from "./token.js"; +import { UpstreamError, parseUpstreamProxy } from "./upstream.js"; + +function report(message: string): void { + process.stderr.write(`[ado-proxy] ${message}\n`); +} + +/** + * Where the CA private key lives. + * + * AWF mounts tmpfs at this path so the key never touches a host filesystem or + * any volume the agent can see. When it is absent — as in tests — fall back to + * a private temporary directory rather than failing, since the key is + * regenerated per process either way. + */ +function keyDirectory(): string { + const configured = process.env.AWF_POLICY_PROXY_TMPFS_DIR; + if (configured !== undefined && configured !== "") { + return join(configured, "ado-proxy-ca"); + } + return mkdtempSync(join(tmpdir(), "ado-proxy-ca-")); +} + +/** Start the proxy and resolve with the process exit code once it stops. */ +export async function run(argv: readonly string[]): Promise { + let config: ProxyConfig; + try { + config = loadConfig(argv); + } catch (error) { + if (!(error instanceof ConfigError)) throw error; + // Fail closed and say why: a proxy that starts without a valid policy + // would be an open tunnel to the protected hosts. + report(`configuration error: ${error.message}`); + return 1; + } + + try { + parseUpstreamProxy(config.upstreamProxy); + } catch (error) { + if (!(error instanceof UpstreamError)) throw error; + report(`configuration error: ${error.message}`); + return 1; + } + + let ca; + try { + ca = mintCa(keyDirectory(), config.policy.protected_hosts); + publishCaCertificate(config.publicCaFile, ca.caCertPem); + } catch (error) { + if (!(error instanceof CaError)) throw error; + // Without a trusted CA the agent's clients reject interception, and the + // only "fix" would be to stop intercepting — which is the thing this proxy + // exists to prevent. + report(`cannot establish the interception CA: ${error.message}`); + return 1; + } + + const server = createProxyServer({ + config, + ca, + tokens: new TokenSource(config.tokenFile), + log: new DecisionLog(config.logDir), + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(config.listenPort, config.listenAddress, resolve); + }); + + report( + `listening on ${config.listenAddress}:${config.listenPort}; ` + + `org=${config.policy.organization} project=${config.policy.project} ` + + `capabilities=${config.policy.capabilities.join(",") || "(none)"} ` + + `protected=${config.policy.protected_hosts.join(",")}`, + ); + + // AWF stops the sidecar once the agent exits. Close politely so buffered + // decision-log lines are flushed, but do not wait forever for a hung tunnel. + await new Promise((resolve) => { + const shutdown = (signal: string): void => { + report(`received ${signal}; shutting down`); + server.close(() => resolve()); + setTimeout(resolve, 5_000).unref(); + }; + process.once("SIGTERM", () => shutdown("SIGTERM")); + process.once("SIGINT", () => shutdown("SIGINT")); + }); + + return 0; +} + +async function main(): Promise { + process.exitCode = await run(process.argv.slice(2)); +} + +void main(); diff --git a/scripts/ado-script/src/ado-proxy/log.ts b/scripts/ado-script/src/ado-proxy/log.ts new file mode 100644 index 000000000..8b704288e --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/log.ts @@ -0,0 +1,84 @@ +/** + * Sanitized decision log. + * + * This stream is copied into the Agent's published artifacts and read by + * `ado-aw audit`, so it is written under the assumption that the agent will + * read it. It therefore records *shapes and outcomes*, never content: no + * headers, no bodies, no query values, no URLs beyond the normalized operation + * id and the scope identifiers the policy already pinned. + */ +import { appendFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +/** Schema version, so `ado-aw audit` can evolve its reader independently. */ +export const DECISION_LOG_SCHEMA = "ado-aw/ado-proxy-decisions/v1"; + +export interface DecisionRecord { + /** ISO-8601 timestamp. */ + readonly ts: string; + /** Correlates the request across the allow decision and the response. */ + readonly request_id: string; + readonly host: string; + readonly method: string; + /** Catalog operation id, when one matched. */ + readonly operation?: string; + readonly decision: "allow" | "deny" | "error"; + /** Machine-readable denial reason; absent on allow. */ + readonly reason?: string; + /** Short human-readable detail. Never contains request content. */ + readonly detail?: string; + /** Upstream status class (`2xx`, `4xx`, …), not the exact code. */ + readonly upstream_status_class?: string; + readonly latency_ms?: number; + readonly response_bytes?: number; + /** Credential headers the client supplied and the proxy stripped. */ + readonly stripped_credentials?: readonly string[]; +} + +/** + * Append-only JSONL writer. + * + * Failures to write are swallowed after the first report: losing audit lines is + * bad, but killing the proxy — and therefore the agent's only route to Azure + * DevOps — because a log volume filled up would be worse. + */ +export class DecisionLog { + readonly #path: string | undefined; + #warned = false; + + constructor(logDir: string | undefined) { + if (logDir === undefined) { + this.#path = undefined; + return; + } + try { + mkdirSync(logDir, { recursive: true }); + this.#path = join(logDir, "ado-proxy-decisions.jsonl"); + appendFileSync(this.#path, `${JSON.stringify({ schema: DECISION_LOG_SCHEMA })}\n`); + } catch (error) { + process.stderr.write( + `[ado-proxy] decision log disabled: ${(error as Error).message}\n`, + ); + this.#path = undefined; + } + } + + write(record: DecisionRecord): void { + if (this.#path === undefined) return; + try { + appendFileSync(this.#path, `${JSON.stringify(record)}\n`); + } catch (error) { + if (!this.#warned) { + this.#warned = true; + process.stderr.write( + `[ado-proxy] decision log write failed: ${(error as Error).message}\n`, + ); + } + } + } +} + +/** Bucket an HTTP status into its class, so exact upstream codes never leak. */ +export function statusClass(status: number): string { + return `${Math.floor(status / 100)}xx`; +} diff --git a/scripts/ado-script/src/ado-proxy/policy.test.ts b/scripts/ado-script/src/ado-proxy/policy.test.ts new file mode 100644 index 000000000..a57e7de24 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/policy.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; + +import { CATALOG_SCHEMA_VERSION } from "./catalog.js"; +import type { ProxyPolicy } from "./config.js"; +import { authorize, type Decision } from "./policy.js"; +import { normalizeTarget } from "./route.js"; + +const POLICY: ProxyPolicy = { + catalog_version: CATALOG_SCHEMA_VERSION, + organization: "contoso", + project: "Widgets", + project_id: "11111111-1111-1111-1111-111111111111", + repository: "widget-api", + repository_id: "22222222-2222-2222-2222-222222222222", + capabilities: ["discovery", "core", "repos", "pipelines", "boards"], + protected_hosts: ["dev.azure.com", "app.vssps.visualstudio.com"], + allowed_resource_areas: ["79134c72-4a58-4b42-976c-04e7115f32bf"], +}; + +function decide( + method: string, + url: string, + options: { host?: string; accept?: string; policy?: ProxyPolicy } = {}, +): Decision { + return authorize( + { + method, + host: options.host ?? "dev.azure.com", + target: normalizeTarget(url), + accept: options.accept, + }, + options.policy ?? POLICY, + ); +} + +function expectDeny(decision: Decision, reason: string): void { + expect(decision.allow).toBe(false); + if (decision.allow) return; + expect(decision.reason).toBe(reason); +} + +describe("authorize — allowed reads", () => { + it("allows the current project", () => { + const decision = decide("GET", "/contoso/_apis/projects/Widgets?api-version=7.1"); + expect(decision.allow).toBe(true); + if (decision.allow) expect(decision.operation.id).toBe("core.project-get"); + }); + + it("accepts the project GUID as well as the name", () => { + // `az` substitutes whichever identifier it cached, so both must work — but + // only for the pinned project. + expect( + decide( + "GET", + `/contoso/_apis/projects/${POLICY.project_id as string}?api-version=7.1`, + ).allow, + ).toBe(true); + }); + + it("allows discovery OPTIONS without an api-version", () => { + expect(decide("OPTIONS", "/contoso/_apis").allow).toBe(true); + }); + + it("allows a repository read in the current project", () => { + const decision = decide( + "GET", + "/contoso/Widgets/_apis/git/repositories/widget-api/refs?api-version=7.1&filter=heads", + ); + expect(decision.allow).toBe(true); + }); + + it("allows the SPS resource-area fallback for an allowed area", () => { + expect( + decide( + "GET", + "/_apis/resourceareas/79134c72-4a58-4b42-976c-04e7115f32bf?api-version=7.1", + { host: "app.vssps.visualstudio.com" }, + ).allow, + ).toBe(true); + }); + + it("reads the api-version from the Accept header", () => { + expect( + decide("GET", "/contoso/_apis/projects/Widgets", { + accept: "application/json;api-version=7.1;excludeUrls=true", + }).allow, + ).toBe(true); + }); +}); + +describe("authorize — denials", () => { + it("denies every non-read method", () => { + for (const method of ["POST", "PUT", "PATCH", "DELETE", "HEAD", "TRACE"]) { + expectDeny(decide(method, "/contoso/_apis/projects/Widgets?api-version=7.1"), "method-not-read"); + } + }); + + it("denies an unknown host", () => { + expectDeny( + decide("GET", "/contoso/_apis/projects/Widgets?api-version=7.1", { + host: "evil.test", + }), + "unknown-host", + ); + }); + + it("denies credential-bearing route families outright", () => { + for (const path of [ + "/contoso/Widgets/_apis/serviceendpoint/endpoints?api-version=7.1", + "/contoso/Widgets/_apis/distributedtask/variablegroups?api-version=7.1", + "/contoso/Widgets/_apis/distributedtask/securefiles?api-version=7.1", + "/contoso/Widgets/_git/widget-api/info/refs?service=git-upload-pack", + ]) { + expectDeny(decide("GET", path), "denied-route-family"); + } + }); + + it("denies placeholder-bearing families such as the build OAuth token", () => { + // These are the families defence-in-depth exists for: they are not in the + // allowlist either, but a future catalog mistake must not make them + // reachable. + for (const path of [ + "/contoso/Widgets/_apis/build/builds/42/oauthtoken?api-version=7.1", + "/contoso/Widgets/_apis/build/builds/42/artifacts?api-version=7.1", + "/contoso/Widgets/_apis/build/builds/42/logs?api-version=7.1", + "/contoso/Widgets/_apis/git/repositories/widget-api/blobs?api-version=7.1", + "/contoso/Widgets/_apis/git/repositories/widget-api/itemsbatch?api-version=7.1", + ]) { + expectDeny(decide("GET", path), "denied-route-family"); + } + }); + + it("denies the batch work-item read while leaving the single read reachable", () => { + expectDeny( + decide("GET", "/contoso/_apis/wit/workitems?ids=1,2,3&api-version=7.1"), + "denied-route-family", + ); + expect(decide("GET", "/contoso/_apis/wit/workitems/42?api-version=7.1").allow).toBe( + true, + ); + }); + + it("denies an uncatalogued route", () => { + expectDeny( + decide("GET", "/contoso/_apis/graph/users?api-version=7.1"), + "unknown-route", + ); + }); + + it("denies an operation whose capability is disabled", () => { + const decision = decide( + "GET", + "/contoso/Widgets/_apis/wit/workitems/42?api-version=7.1", + { policy: { ...POLICY, capabilities: ["discovery", "core"] } }, + ); + expectDeny(decision, "capability-disabled"); + }); + + it("denies a different organization", () => { + expectDeny( + decide("GET", "/fabrikam/_apis/projects/Widgets?api-version=7.1"), + "out-of-scope", + ); + }); + + it("denies a different project", () => { + expectDeny( + decide("GET", "/contoso/_apis/projects/Secrets?api-version=7.1"), + "out-of-scope", + ); + expectDeny( + decide( + "GET", + "/contoso/Secrets/_apis/build/builds?api-version=7.1", + ), + "out-of-scope", + ); + }); + + it("denies a different repository in the current project", () => { + expectDeny( + decide( + "GET", + "/contoso/Widgets/_apis/git/repositories/other-repo/items?api-version=7.1", + ), + "out-of-scope", + ); + }); + + it("denies a resource area outside the allowed set", () => { + expectDeny( + decide( + "GET", + "/_apis/resourceareas/99999999-9999-9999-9999-999999999999?api-version=7.1", + { host: "app.vssps.visualstudio.com" }, + ), + "out-of-scope", + ); + }); + + it("denies an unlisted query parameter", () => { + // Unknown parameters change what an endpoint returns; `$expand` in + // particular can pull in fields the catalog never reviewed. + expectDeny( + decide("GET", "/contoso/_apis/projects/Widgets?api-version=7.1&$expand=all"), + "query-not-allowed", + ); + }); + + it("denies a missing, conflicting, or out-of-range api-version", () => { + expectDeny(decide("GET", "/contoso/_apis/projects/Widgets"), "api-version"); + expectDeny( + decide("GET", "/contoso/_apis/projects/Widgets?api-version=1.0"), + "api-version", + ); + expectDeny( + decide("GET", "/contoso/_apis/projects/Widgets?api-version=7.1", { + accept: "application/json;api-version=3.0", + }), + "api-version", + ); + }); + + it("denies an api-version on a discovery OPTIONS", () => { + expectDeny(decide("OPTIONS", "/contoso/_apis?api-version=7.1"), "api-version"); + }); + + it("reports a capability denial only when nothing else matches", () => { + // `core` is enabled here, so the project read must still be allowed even + // though `boards` is off. + const decision = decide("GET", "/contoso/_apis/projects/Widgets?api-version=7.1", { + policy: { ...POLICY, capabilities: ["discovery", "core"] }, + }); + expect(decision.allow).toBe(true); + }); +}); + +describe("authorize — response-scoped operations", () => { + it("allows the org-level pull-request read that az repos pr show needs", () => { + // Its URL carries no project or repository, so the scope check happens on + // the response body instead. + const decision = decide("GET", "/contoso/_apis/git/pullrequests/7?api-version=7.1"); + expect(decision.allow).toBe(true); + if (decision.allow) { + expect(decision.operation.response).toBe("validate-project-and-repository"); + } + }); + + it("allows the org-level work-item read that az boards work-item show needs", () => { + const decision = decide("GET", "/contoso/_apis/wit/workitems/42?api-version=7.1"); + expect(decision.allow).toBe(true); + if (decision.allow) expect(decision.operation.response).toBe("validate-project"); + }); +}); diff --git a/scripts/ado-script/src/ado-proxy/policy.ts b/scripts/ado-script/src/ado-proxy/policy.ts new file mode 100644 index 000000000..e3878e77c --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/policy.ts @@ -0,0 +1,295 @@ +/** + * The authorization decision for a protected request. + * + * Deny-by-default throughout: a request is allowed only when it matches a + * catalogued operation whose capability is enabled, whose API version is in + * range, whose every query parameter is explicitly permitted, and whose scope + * resolves to the organization/project/repository the compiler pinned. Any gap + * — an unknown route, an unlisted parameter, an unmatched placeholder — is a + * denial, never a pass-through. + */ +import { ApiVersionError, resolveApiVersion, type ApiVersion } from "./api-version.js"; +import { DENIED_ROUTE_FAMILIES, OPERATIONS, PROTECTED_HOSTS } from "./catalog.js"; +import type { ProxyPolicy } from "./config.js"; +import { + matchRoute, + matchesDeniedFamily, + type NormalizedTarget, + type RouteParams, +} from "./route.js"; +import type { Capability, Operation } from "../shared/ado-proxy-catalog.types.gen.js"; + +/** Why a request was refused, in a form safe to log and to return. */ +export type DenyReason = + | "method-not-read" + | "unknown-host" + | "denied-route-family" + | "unknown-route" + | "capability-disabled" + | "api-version" + | "query-not-allowed" + | "out-of-scope"; + +export type Decision = + | { + readonly allow: true; + readonly operation: Operation; + readonly params: RouteParams; + readonly apiVersion?: ApiVersion; + } + | { + readonly allow: false; + readonly reason: DenyReason; + readonly detail: string; + /** Operation id when the route matched but a later check failed. */ + readonly operationId?: string; + }; + +function deny( + reason: DenyReason, + detail: string, + operationId?: string, +): Decision { + return operationId === undefined + ? { allow: false, reason, detail } + : { allow: false, reason, detail, operationId }; +} + +/** Case-insensitive identifier comparison, as Azure DevOps treats names. */ +function sameIdentifier(left: string, right: string | undefined): boolean { + return right !== undefined && left.toLowerCase() === right.toLowerCase(); +} + +/** + * True when a path value names the pinned project. + * + * Clients use the name in some calls and the GUID in others — `az` in + * particular substitutes whichever it cached — so both are accepted, but only + * for the single project the compiler pinned. + */ +function isCurrentProject(value: string, policy: ProxyPolicy): boolean { + return ( + sameIdentifier(value, policy.project) || sameIdentifier(value, policy.project_id) + ); +} + +/** True when a path value names the pinned repository. */ +function isCurrentRepository(value: string, policy: ProxyPolicy): boolean { + return ( + sameIdentifier(value, policy.repository) || + sameIdentifier(value, policy.repository_id) + ); +} + +/** + * Resolve the concrete host an operation's {@link Operation.host} policy names. + * + * The catalog stores a *policy* rather than a hostname so the same catalog can + * describe the organization host without the compiler having to rewrite it per + * run. + */ +function hostFor(operation: Operation): string | undefined { + const [organizationHost, spsFallbackHost] = PROTECTED_HOSTS; + return operation.host === "current-organization" ? organizationHost : spsFallbackHost; +} + +function checkQuery( + operation: Operation, + target: NormalizedTarget, +): Decision | undefined { + const allowed = new Set(operation.allowed_query.map((name) => name.toLowerCase())); + const denied = new Set(operation.denied_query.map((name) => name.toLowerCase())); + + for (const [rawName] of target.query) { + const name = rawName.toLowerCase(); + // The version is validated separately and is legal on every versioned + // operation, so it is never listed in `allowed_query`. + if (name === "api-version") continue; + if (denied.has(name)) { + return deny("query-not-allowed", `parameter ${name} is denied`, operation.id); + } + if (!allowed.has(name)) { + return deny( + "query-not-allowed", + `parameter ${name} is not permitted on this operation`, + operation.id, + ); + } + } + return undefined; +} + +function checkScope( + operation: Operation, + params: RouteParams, + policy: ProxyPolicy, +): Decision | undefined { + const organization = params.org; + // Every organization-hosted route carries `{org}`; the SPS fallback route + // does not, and is scoped by resource-area id instead. + if (operation.host === "current-organization") { + if (organization === undefined || !sameIdentifier(organization, policy.organization)) { + return deny( + "out-of-scope", + "request names a different organization than the pinned one", + operation.id, + ); + } + } + + switch (operation.scope) { + case "current-organization": + case "filter-projects-to-current": + case "filter-resource-areas": + case "response-current-project": + case "response-current-repository": + // Organization scope already checked; the response-scoped variants are + // additionally validated against the body once it arrives, because their + // URL carries no project or repository segment to check here. + return undefined; + + case "allowed-resource-area": { + const areaId = params.areaId; + if ( + areaId === undefined || + !policy.allowed_resource_areas.some((allowed) => sameIdentifier(areaId, allowed)) + ) { + return deny( + "out-of-scope", + "resource area is not in the allowed set", + operation.id, + ); + } + return undefined; + } + + case "current-project-path": { + const project = params.project; + if (project === undefined || !isCurrentProject(project, policy)) { + return deny( + "out-of-scope", + "request names a different project than the pinned one", + operation.id, + ); + } + return undefined; + } + + case "current-repository-path": { + const project = params.project; + const repository = params.repository; + if (project === undefined || !isCurrentProject(project, policy)) { + return deny( + "out-of-scope", + "request names a different project than the pinned one", + operation.id, + ); + } + if (repository === undefined || !isCurrentRepository(repository, policy)) { + return deny( + "out-of-scope", + "request names a different repository than the pinned one", + operation.id, + ); + } + return undefined; + } + + default: { + // An unhandled scope policy must never mean "allowed"; a new variant + // added in Rust fails closed here until it is implemented. + const exhaustive: never = operation.scope; + return deny("out-of-scope", `unimplemented scope policy ${String(exhaustive)}`, operation.id); + } + } +} + +/** Inputs to a single authorization decision. */ +export interface RequestFacts { + readonly method: string; + /** Canonical host, already confirmed protected and without a port. */ + readonly host: string; + readonly target: NormalizedTarget; + readonly accept: string | undefined; +} + +/** + * Authorize one protected request. + * + * Ordering matters and is deliberate: method and denied-family checks run + * before route matching so that a mutation or a credential-bearing family is + * reported as such rather than as a generic "unknown route", which is what an + * author needs to see to understand the denial. + */ +export function authorize(facts: RequestFacts, policy: ProxyPolicy): Decision { + const method = facts.method.toUpperCase(); + if (method !== "GET" && method !== "OPTIONS") { + return deny("method-not-read", `${method} is not a read method`); + } + + const [organizationHost, spsFallbackHost] = PROTECTED_HOSTS; + if (facts.host !== organizationHost && facts.host !== spsFallbackHost) { + return deny("unknown-host", "host is not a catalogued Azure DevOps host"); + } + + const deniedFamily = matchesDeniedFamily( + facts.target.segments, + DENIED_ROUTE_FAMILIES, + facts.target.query, + ); + if (deniedFamily !== undefined) { + return deny("denied-route-family", `route family ${deniedFamily} is always denied`); + } + + const enabled = new Set(policy.capabilities); + let capabilityBlocked: Operation | undefined; + + for (const operation of OPERATIONS) { + if (operation.method !== method) continue; + if (hostFor(operation) !== facts.host) continue; + + const params = matchRoute(operation.route, facts.target.segments); + if (params === undefined) continue; + + if (!enabled.has(operation.capability)) { + // Remember it, but keep looking: another capability may catalogue the + // same shape, and reporting the disabled capability is only right when + // nothing else matches. + capabilityBlocked ??= operation; + continue; + } + + let apiVersion: ApiVersion | undefined; + try { + apiVersion = resolveApiVersion( + operation.api_version, + facts.target.query, + facts.accept, + ); + } catch (error) { + if (error instanceof ApiVersionError) { + return deny("api-version", error.message, operation.id); + } + throw error; + } + + const queryDenial = checkQuery(operation, facts.target); + if (queryDenial !== undefined) return queryDenial; + + const scopeDenial = checkScope(operation, params, policy); + if (scopeDenial !== undefined) return scopeDenial; + + return apiVersion === undefined + ? { allow: true, operation, params } + : { allow: true, operation, params, apiVersion }; + } + + if (capabilityBlocked !== undefined) { + return deny( + "capability-disabled", + `operation requires the ${capabilityBlocked.capability} capability`, + capabilityBlocked.id, + ); + } + return deny("unknown-route", "no catalogued operation matches this request"); +} diff --git a/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts new file mode 100644 index 000000000..21134a8da --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts @@ -0,0 +1,592 @@ +/** + * End-to-end test of the proxy against a fake Squid and a fake Azure DevOps. + * + * The unit suites cover each decision in isolation; this one proves the wiring + * that actually protects the credential: + * + * - an allowed read reaches the upstream carrying the injected bearer, and + * the sentinel the client supplied is gone; + * - every denial is refused *before* the upstream is contacted, so a rejected + * request cannot consume, observe, or exercise the credential; + * - a non-protected destination is byte-tunnelled to Squid untouched, keeping + * its own certificate end to end; + * - a missing credential is an infrastructure failure, never an + * unauthenticated pass-through. + * + * The canary bearer is asserted absent from every response body the client + * sees, so a future refactor that echoes upstream detail back to the agent + * fails here. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer as createHttpServer, request as httpRequest, type Server } from "node:http"; +import { connect as netConnect, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + connect as tlsConnect, + createServer as createTlsServer, + type TlsOptions, +} from "node:tls"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { mintCa, type CaMaterials } from "./ca.js"; +import { CATALOG_SCHEMA_VERSION } from "./catalog.js"; +import type { ProxyConfig, ProxyPolicy } from "./config.js"; +import { DecisionLog } from "./log.js"; +import { HEALTH_PATH, createProxyServer } from "./server.js"; +import { TokenSource } from "./token.js"; + +/** + * Locate `openssl`. + * + * CI runs on Linux where it is always on PATH. On a Windows dev machine it + * usually ships with Git but is not exported, so look there too rather than + * silently skipping the suite that proves the security boundary. + */ +function ensureOpenssl(): boolean { + const candidates = [ + "C:\\Program Files\\Git\\usr\\bin", + "C:\\Program Files\\Git\\mingw64\\bin", + ]; + try { + execFileSync("openssl", ["version"], { stdio: "ignore" }); + return true; + } catch { + for (const directory of candidates) { + if (!existsSync(join(directory, "openssl.exe"))) continue; + process.env.PATH = `${directory};${process.env.PATH ?? ""}`; + try { + execFileSync("openssl", ["version"], { stdio: "ignore" }); + return true; + } catch { + // Keep looking. + } + } + return false; + } +} + +const CANARY = "canary-bearer-8f2c1d4e9a7b"; +const SENTINEL = "ado-proxy-sentinel-not-a-credential"; +const ORGANIZATION = "contoso"; + +const POLICY: ProxyPolicy = { + catalog_version: CATALOG_SCHEMA_VERSION, + organization: ORGANIZATION, + project: "Widgets", + project_id: "11111111-1111-1111-1111-111111111111", + repository: "widget-api", + repository_id: "22222222-2222-2222-2222-222222222222", + capabilities: ["discovery", "core", "repos", "pipelines", "boards"], + protected_hosts: ["dev.azure.com", "app.vssps.visualstudio.com"], + allowed_resource_areas: [], +}; + +interface UpstreamCall { + readonly method: string; + readonly url: string; + readonly authorization: string | undefined; + readonly headerNames: readonly string[]; +} + +interface Harness { + readonly proxyPort: number; + readonly proxyCaPem: string; + readonly upstreamCalls: UpstreamCall[]; + readonly tunnelTargets: string[]; + readonly tokenFile: string; +} + +let workdir: string; +let harness: Harness; +const servers: { close(callback: () => void): void }[] = []; +const hasOpenssl = ensureOpenssl(); + +function listen(server: { listen: (...args: never[]) => void }): Promise { + return new Promise((resolve) => { + (server as unknown as Server).listen(0, "127.0.0.1", () => { + const address = (server as unknown as Server).address(); + resolve(typeof address === "object" && address !== null ? address.port : 0); + }); + }); +} + +/** A TLS server standing in for `dev.azure.com`. */ +async function startFakeAdo(ca: CaMaterials, calls: UpstreamCall[]): Promise { + const leaf = ca.leaves.get("dev.azure.com"); + if (leaf === undefined) throw new Error("fake upstream has no leaf"); + + const app = createHttpServer((request, response) => { + calls.push({ + method: request.method ?? "", + url: request.url ?? "", + authorization: request.headers.authorization, + headerNames: Object.keys(request.headers), + }); + const body = JSON.stringify({ + count: 2, + value: [ + { id: POLICY.project_id, name: "Widgets" }, + { id: "33333333-3333-3333-3333-333333333333", name: "Secrets" }, + ], + }); + response.writeHead(200, { + "content-type": "application/json", + "set-cookie": "UserAuthentication=should-not-reach-the-agent", + "content-length": Buffer.byteLength(body), + }); + response.end(body); + }); + + const options: TlsOptions = { key: leaf.key, cert: leaf.cert }; + const tls = createTlsServer(options); + tls.on("secureConnection", (socket) => app.emit("connection", socket)); + servers.push(tls); + return listen(tls as never); +} + +/** A TLS server standing in for an ordinary, non-protected host. */ +async function startPlainHost(ca: CaMaterials): Promise { + const leaf = ca.leaves.get("example.test"); + if (leaf === undefined) throw new Error("plain host has no leaf"); + const app = createHttpServer((_request, response) => { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("tunnelled"); + }); + const tls = createTlsServer({ key: leaf.key, cert: leaf.cert }); + tls.on("secureConnection", (socket) => app.emit("connection", socket)); + servers.push(tls); + return listen(tls as never); +} + +/** + * A minimal Squid: accepts CONNECT and dials the mapped local port. + * + * The mapping is what lets the proxy keep using the real hostnames — and + * therefore the real policy — while the sockets stay on loopback. + */ +async function startFakeSquid( + routes: ReadonlyMap, + seen: string[], + plainHttpPort: number, +): Promise { + const squid = createHttpServer((request, response) => { + // Absolute-form cleartext, exactly as a client with HTTP_PROXY set would + // send it. Squid resolves the host itself; the fake maps every allowed + // cleartext host onto one loopback origin. + const target = request.url ?? ""; + seen.push(target); + if (!target.startsWith("http://example.test/")) { + response.writeHead(403).end(); + return; + } + const upstream = httpRequest( + { + host: "127.0.0.1", + port: plainHttpPort, + method: request.method, + path: new URL(target).pathname, + }, + (upstreamResponse) => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }, + ); + upstream.on("error", () => response.writeHead(502).end()); + request.pipe(upstream); + }); + squid.on("connect", (request, clientSocket: Socket, head: Buffer) => { + const target = request.url ?? ""; + seen.push(target); + const port = routes.get(target); + if (port === undefined) { + clientSocket.end("HTTP/1.1 403 Forbidden\r\n\r\n"); + return; + } + const upstream = netConnect({ host: "127.0.0.1", port }, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) upstream.write(head); + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + upstream.on("error", () => clientSocket.destroy()); + clientSocket.on("error", () => upstream.destroy()); + }); + servers.push(squid); + return listen(squid as never); +} + +/** A cleartext origin behind the fake Squid, standing in for an http:// feed. */ +async function startPlainHttpOrigin(): Promise { + const app = createHttpServer((_request, response) => { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("plain-http"); + }); + servers.push(app); + return listen(app as never); +} + +interface ClientResponse { + readonly status: number; + readonly body: string; + readonly headers: Readonly>; +} + +/** Issue a request through the proxy exactly as a client with proxy env would. */ +function requestThroughProxy( + proxyPort: number, + host: string, + path: string, + options: { + method?: string; + headers?: Record; + ca: string; + }, +): Promise { + return new Promise((resolve, reject) => { + const socket = netConnect({ host: "127.0.0.1", port: proxyPort }, () => { + socket.write(`CONNECT ${host}:443 HTTP/1.1\r\nHost: ${host}:443\r\n\r\n`); + }); + + let preamble = ""; + const onData = (chunk: Buffer): void => { + preamble += chunk.toString("latin1"); + const end = preamble.indexOf("\r\n\r\n"); + if (end === -1) return; + socket.removeListener("data", onData); + + const statusLine = preamble.slice(0, preamble.indexOf("\r\n")); + if (!statusLine.includes("200")) { + socket.destroy(); + reject(new Error(`proxy refused CONNECT: ${statusLine}`)); + return; + } + const leftover = preamble.slice(end + 4); + if (leftover.length > 0) socket.unshift(Buffer.from(leftover, "latin1")); + + const secured = tlsConnect({ socket, servername: host, ca: options.ca }, () => { + const headerLines = Object.entries(options.headers ?? {}) + .map(([name, value]) => `${name}: ${value}\r\n`) + .join(""); + secured.write( + `${options.method ?? "GET"} ${path} HTTP/1.1\r\nHost: ${host}\r\n` + + `${headerLines}Connection: close\r\n\r\n`, + ); + }); + + let raw = ""; + secured.on("data", (chunk: Buffer) => { + raw += chunk.toString("utf8"); + }); + secured.on("error", reject); + secured.on("close", () => { + const headerEnd = raw.indexOf("\r\n\r\n"); + const head = headerEnd === -1 ? raw : raw.slice(0, headerEnd); + const body = headerEnd === -1 ? "" : raw.slice(headerEnd + 4); + const headers: Record = {}; + for (const line of head.split("\r\n").slice(1)) { + const colon = line.indexOf(":"); + if (colon === -1) continue; + headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim(); + } + resolve({ + status: Number(head.split("\r\n")[0]?.split(" ")[1] ?? 0), + body, + headers, + }); + }); + }; + + socket.on("data", onData); + socket.on("error", reject); + }); +} + +/** Issue an absolute-form cleartext request, as a client with HTTP_PROXY does. */ +function plainHttpThroughProxy(proxyPort: number, target: string): Promise { + return new Promise((resolve, reject) => { + const request = httpRequest( + { host: "127.0.0.1", port: proxyPort, method: "GET", path: target }, + (response) => { + let body = ""; + response.on("data", (chunk: Buffer) => { + body += chunk.toString("utf8"); + }); + response.on("end", () => + resolve({ status: response.statusCode ?? 0, body, headers: response.headers }), + ); + }, + ); + request.on("error", reject); + request.end(); + }); +} + +beforeAll(async () => { + if (!hasOpenssl) return; + workdir = mkdtempSync(join(tmpdir(), "ado-proxy-e2e-")); + + const upstreamCa = mintCa(join(workdir, "upstream-ca"), ["dev.azure.com"]); + const plainCa = mintCa(join(workdir, "plain-ca"), ["example.test"]); + const proxyCa = mintCa(join(workdir, "proxy-ca"), POLICY.protected_hosts); + + const upstreamCalls: UpstreamCall[] = []; + const tunnelTargets: string[] = []; + const adoPort = await startFakeAdo(upstreamCa, upstreamCalls); + const plainPort = await startPlainHost(plainCa); + const plainHttpPort = await startPlainHttpOrigin(); + const squidPort = await startFakeSquid( + new Map([ + ["dev.azure.com:443", adoPort], + ["example.test:443", plainPort], + ]), + tunnelTargets, + plainHttpPort, + ); + + const tokenFile = join(workdir, "token"); + writeFileSync(tokenFile, `${CANARY}\n`, { mode: 0o600 }); + + const config: ProxyConfig = { + listenAddress: "127.0.0.1", + listenPort: 0, + upstreamProxy: `http://127.0.0.1:${squidPort}`, + tokenFile, + publicCaFile: join(workdir, "ca.pem"), + policy: POLICY, + }; + + const server = createProxyServer({ + config, + ca: proxyCa, + tokens: new TokenSource(tokenFile), + log: new DecisionLog(join(workdir, "decisions")), + upstreamCa: upstreamCa.caCertPem, + }); + servers.push(server); + const proxyPort = await listen(server as never); + + harness = { + proxyPort, + proxyCaPem: proxyCa.caCertPem, + upstreamCalls, + tunnelTargets, + tokenFile, + }; + + // Keep the plain CA reachable for the tunnel assertion. + plainCaPem = plainCa.caCertPem; +}); + +let plainCaPem = ""; + +afterAll(async () => { + await Promise.all( + servers.map( + (server) => + new Promise((resolve) => { + // Tunnelled sockets stay open by design, and the intercepting inner + // servers hold their own. Ask politely, then stop waiting — teardown + // is not what these tests are proving. + (server as { closeAllConnections?: () => void }).closeAllConnections?.(); + server.close(() => resolve()); + setTimeout(resolve, 500).unref(); + }), + ), + ); + if (workdir !== undefined) rmSync(workdir, { recursive: true, force: true }); +}); + +const suite = hasOpenssl ? describe : describe.skip; + +suite("ado-proxy end to end", () => { + it("injects the bearer only on an allowed read, and strips the client's", async () => { + const before = harness.upstreamCalls.length; + const response = await requestThroughProxy( + harness.proxyPort, + "dev.azure.com", + `/${ORGANIZATION}/_apis/projects?api-version=7.1&stateFilter=all&$top=1&$skip=0`, + { + ca: harness.proxyCaPem, + headers: { + // What `az devops` sends once AZURE_DEVOPS_EXT_PAT is set. + Authorization: `Basic ${Buffer.from(`:${SENTINEL}`).toString("base64")}`, + Accept: "application/json;api-version=7.1", + }, + }, + ); + + expect(response.status).toBe(200); + const call = harness.upstreamCalls[before]; + expect(call).toBeDefined(); + expect(call?.authorization).toBe(`Bearer ${CANARY}`); + // The sentinel must not survive in any form. + expect(call?.authorization).not.toContain(SENTINEL); + expect(call?.headerNames).not.toContain("cookie"); + }); + + it("filters the response and never returns upstream session material", async () => { + const response = await requestThroughProxy( + harness.proxyPort, + "dev.azure.com", + `/${ORGANIZATION}/_apis/projects?api-version=7.1&stateFilter=all&$top=1&$skip=0`, + { ca: harness.proxyCaPem }, + ); + const body = JSON.parse(response.body) as { count: number; value: { name: string }[] }; + // The upstream returned two projects; the agent may only learn about one. + expect(body.count).toBe(1); + expect(body.value[0]?.name).toBe("Widgets"); + expect(response.headers["set-cookie"]).toBeUndefined(); + expect(response.body).not.toContain(CANARY); + }); + + it("refuses a write without contacting the upstream", async () => { + const before = harness.upstreamCalls.length; + const response = await requestThroughProxy( + harness.proxyPort, + "dev.azure.com", + `/${ORGANIZATION}/_apis/wit/workitems/$Bug?api-version=7.1`, + { ca: harness.proxyCaPem, method: "POST" }, + ); + expect(response.status).toBe(403); + // The credential must never be exercised on a request that was denied. + expect(harness.upstreamCalls.length).toBe(before); + expect(response.body).not.toContain(CANARY); + }); + + it("refuses a cross-project read without contacting the upstream", async () => { + const before = harness.upstreamCalls.length; + const response = await requestThroughProxy( + harness.proxyPort, + "dev.azure.com", + `/${ORGANIZATION}/_apis/projects/Secrets?api-version=7.1`, + { ca: harness.proxyCaPem }, + ); + expect(response.status).toBe(403); + expect(harness.upstreamCalls.length).toBe(before); + }); + + it("refuses an uncatalogued route without contacting the upstream", async () => { + const before = harness.upstreamCalls.length; + const response = await requestThroughProxy( + harness.proxyPort, + "dev.azure.com", + `/${ORGANIZATION}/_apis/serviceendpoint/endpoints?api-version=7.1`, + { ca: harness.proxyCaPem }, + ); + expect(response.status).toBe(403); + expect(harness.upstreamCalls.length).toBe(before); + }); + + it("byte-tunnels a non-protected host end to end", async () => { + const before = harness.upstreamCalls.length; + const response = await requestThroughProxy( + harness.proxyPort, + "example.test", + "/anything", + // Trusting only the plain host's own CA proves the proxy did not + // terminate this connection: an intercepted one would present the + // proxy's certificate and fail verification here. + { ca: plainCaPem }, + ); + expect(response.status).toBe(200); + // The tunnelled response is transfer-encoded; the point is that the body + // arrived intact from the origin, not its framing. + expect(response.body).toContain("tunnelled"); + expect(harness.tunnelTargets).toContain("example.test:443"); + expect(harness.upstreamCalls.length).toBe(before); + }); + + it("answers the readiness probe without revealing policy detail", async () => { + // AWF polls this before starting the agent so the agent cannot race a + // proxy that has not finished minting its CA. + const response = await plainHttpThroughProxy(harness.proxyPort, HEALTH_PATH); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toEqual({ status: "ok" }); + }); + + it("refuses any other origin-form request rather than acting as a relay", async () => { + const response = await plainHttpThroughProxy(harness.proxyPort, "/anything"); + expect(response.status).toBe(403); + }); + + it("relays plain HTTP for a non-protected host so http:// sources keep working", async () => { + // The agent's HTTP_PROXY points here, so refusing cleartext would silently + // break any http:// package source. + const response = await plainHttpThroughProxy( + harness.proxyPort, + `http://example.test/plain`, + ); + expect(response.status).toBe(200); + expect(response.body).toContain("plain-http"); + }); + + it("refuses cleartext to a protected host", async () => { + const before = harness.upstreamCalls.length; + const response = await plainHttpThroughProxy( + harness.proxyPort, + `http://dev.azure.com/${ORGANIZATION}/_apis/projects/Widgets?api-version=7.1`, + ); + // Relaying this would put the policy path — and the bearer — on an + // unencrypted hop. + expect(response.status).toBe(403); + expect(response.body).toContain("HTTPS"); + expect(harness.upstreamCalls.length).toBe(before); + }); + + it("returns denials in the WrappedException shape clients can surface", async () => { + const response = await requestThroughProxy( + harness.proxyPort, + "dev.azure.com", + `/${ORGANIZATION}/_apis/projects/Secrets?api-version=7.1`, + { ca: harness.proxyCaPem }, + ); + const body = JSON.parse(response.body) as { message: string; typeKey: string }; + // `az` and every msrest-based SDK read `message`; without this shape a + // denial surfaces as "unexpected response" with no actionable detail. + expect(body.typeKey).toBe("out-of-scope"); + expect(body.message).toContain("ado-proxy"); + // No header that would send a client into a retry or an interactive login. + expect(response.headers["www-authenticate"]).toBeUndefined(); + expect(response.headers["retry-after"]).toBeUndefined(); + expect(response.headers.location).toBeUndefined(); + }); + + it("fails closed, not unauthenticated, when the credential is missing", async () => { + writeFileSync(harness.tokenFile, " \n", { mode: 0o600 }); + const before = harness.upstreamCalls.length; + try { + const response = await requestThroughProxy( + harness.proxyPort, + "dev.azure.com", + `/${ORGANIZATION}/_apis/projects/Widgets?api-version=7.1`, + { ca: harness.proxyCaPem }, + ); + // 502 rather than 401/429/503: msrest retries those, which would turn one + // failure into several upstream calls. + expect(response.status).toBe(502); + expect(harness.upstreamCalls.length).toBe(before); + } finally { + writeFileSync(harness.tokenFile, `${CANARY}\n`, { mode: 0o600 }); + } + }); + + it("picks up a rotated token without a restart", async () => { + const rotated = "rotated-bearer-1a2b3c4d"; + writeFileSync(harness.tokenFile, `${rotated}\n`, { mode: 0o600 }); + try { + const before = harness.upstreamCalls.length; + await requestThroughProxy( + harness.proxyPort, + "dev.azure.com", + `/${ORGANIZATION}/_apis/projects/Widgets?api-version=7.1`, + { ca: harness.proxyCaPem }, + ); + expect(harness.upstreamCalls[before]?.authorization).toBe(`Bearer ${rotated}`); + } finally { + writeFileSync(harness.tokenFile, `${CANARY}\n`, { mode: 0o600 }); + } + }); +}); diff --git a/scripts/ado-script/src/ado-proxy/response.test.ts b/scripts/ado-script/src/ado-proxy/response.test.ts new file mode 100644 index 000000000..567378e2b --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/response.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import { CATALOG_SCHEMA_VERSION, OPERATIONS } from "./catalog.js"; +import type { ProxyPolicy } from "./config.js"; +import { filterResponse, isProtectedLocation } from "./response.js"; +import type { Operation } from "../shared/ado-proxy-catalog.types.gen.js"; + +const POLICY: ProxyPolicy = { + catalog_version: CATALOG_SCHEMA_VERSION, + organization: "contoso", + project: "Widgets", + project_id: "11111111-1111-1111-1111-111111111111", + repository: "widget-api", + repository_id: "22222222-2222-2222-2222-222222222222", + capabilities: ["discovery", "core", "repos", "pipelines", "boards"], + protected_hosts: ["dev.azure.com", "app.vssps.visualstudio.com"], + allowed_resource_areas: [], +}; + +/** The real catalog entry, so these tests break if a response policy moves. */ +function operation(id: string): Operation { + const found = OPERATIONS.find((entry) => entry.id === id); + if (found === undefined) throw new Error(`no catalog operation ${id}`); + return found; +} + +function apply(id: string, document: unknown): ReturnType { + return filterResponse( + operation(id), + POLICY, + Buffer.from(JSON.stringify(document), "utf8"), + ); +} + +function forwarded(outcome: ReturnType): unknown { + expect(outcome.kind).toBe("forward"); + if (outcome.kind !== "forward") throw new Error("expected forward"); + return JSON.parse(outcome.body.toString("utf8")); +} + +describe("filterResponse — pass-through", () => { + it("forwards a plain JSON operation byte-for-byte", () => { + const body = Buffer.from('{"id":"not even valid for this shape"}', "utf8"); + const outcome = filterResponse(operation("core.project-get"), POLICY, body); + expect(outcome.kind).toBe("forward"); + if (outcome.kind === "forward") expect(outcome.body.equals(body)).toBe(true); + }); +}); + +describe("filterResponse — project list", () => { + it("narrows the list to the pinned project", () => { + // `az devops` lists projects during credential validation; the agent must + // not learn which other projects exist in the organization. + const outcome = apply("core.project-validation-probe", { + count: 3, + value: [ + { id: "11111111-1111-1111-1111-111111111111", name: "Widgets" }, + { id: "33333333-3333-3333-3333-333333333333", name: "Secrets" }, + { id: "44444444-4444-4444-4444-444444444444", name: "Payroll" }, + ], + }); + expect(forwarded(outcome)).toEqual({ + count: 1, + value: [{ id: "11111111-1111-1111-1111-111111111111", name: "Widgets" }], + }); + }); + + it("returns an empty list rather than failing when nothing matches", () => { + expect(forwarded(apply("core.project-validation-probe", { count: 1, value: [{ name: "Other" }] }))) + .toEqual({ count: 0, value: [] }); + }); + + it("denies a list envelope it cannot parse", () => { + const outcome = filterResponse( + operation("core.project-validation-probe"), + POLICY, + Buffer.from("sign in", "utf8"), + ); + expect(outcome.kind).toBe("deny"); + }); +}); + +describe("filterResponse — resource areas", () => { + it("drops areas that point outside the protected set", () => { + // A retained entry would send the client's next call to a host this proxy + // does not police. + const outcome = apply("discovery.resource-areas", { + count: 2, + value: [ + { id: "a", locationUrl: "https://dev.azure.com/contoso/" }, + { id: "b", locationUrl: "https://vsrm.dev.azure.com/contoso/" }, + ], + }); + expect(forwarded(outcome)).toEqual({ + count: 1, + value: [{ id: "a", locationUrl: "https://dev.azure.com/contoso/" }], + }); + }); +}); + +describe("filterResponse — response-scoped validation", () => { + it("allows a work item in the pinned project", () => { + expect( + apply("boards.work-item-get-by-id", { + id: 42, + fields: { "System.TeamProject": "Widgets" }, + }).kind, + ).toBe("forward"); + }); + + it("denies a work item in another project", () => { + // The URL is organization-scoped, so this is the only place the scope can + // be enforced. + const outcome = apply("boards.work-item-get-by-id", { + id: 42, + fields: { "System.TeamProject": "Secrets" }, + }); + expect(outcome.kind).toBe("deny"); + }); + + it("denies a work item that reports no project at all", () => { + expect(apply("boards.work-item-get-by-id", { id: 42 }).kind).toBe("deny"); + }); + + it("allows a pull request in the pinned project and repository", () => { + expect( + apply("repos.pull-request-get-by-id", { + pullRequestId: 7, + repository: { name: "widget-api", project: { name: "Widgets" } }, + }).kind, + ).toBe("forward"); + }); + + it("denies a pull request in another repository of the same project", () => { + const outcome = apply("repos.pull-request-get-by-id", { + pullRequestId: 7, + repository: { name: "other-repo", project: { name: "Widgets" } }, + }); + expect(outcome.kind).toBe("deny"); + }); + + it("denies a pull request in another project", () => { + const outcome = apply("repos.pull-request-get-by-id", { + pullRequestId: 7, + repository: { name: "widget-api", project: { name: "Secrets" } }, + }); + expect(outcome.kind).toBe("deny"); + }); + + it("denies a pull request response with no repository to validate", () => { + expect(apply("repos.pull-request-get-by-id", { pullRequestId: 7 }).kind).toBe("deny"); + }); +}); + +describe("isProtectedLocation", () => { + it("accepts protected hosts and rejects everything else", () => { + expect(isProtectedLocation("https://dev.azure.com/contoso/")).toBe(true); + expect(isProtectedLocation("https://vssps.dev.azure.com/contoso/")).toBe(false); + expect(isProtectedLocation("not a url")).toBe(false); + expect(isProtectedLocation(undefined)).toBe(false); + }); +}); diff --git a/scripts/ado-script/src/ado-proxy/response.ts b/scripts/ado-script/src/ado-proxy/response.ts new file mode 100644 index 000000000..62a2f4ba9 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/response.ts @@ -0,0 +1,174 @@ +/** + * Response-side policy. + * + * Two of the catalog's operations are unavoidably organization-scoped in their + * URL — `az repos pr show` and `az boards work-item show` address a pull + * request or work item by id alone — so the only place their project and + * repository can be checked is the response body. Two more return a *list* of + * things the agent is not scoped to see and must be filtered down. + * + * Filtering happens before a single byte reaches the agent: an out-of-scope + * response is replaced by a denial, never truncated or partially forwarded. + */ +import { PROTECTED_HOSTS } from "./catalog.js"; +import type { ProxyPolicy } from "./config.js"; +import type { Operation, ResponsePolicy } from "../shared/ado-proxy-catalog.types.gen.js"; + +export type FilterOutcome = + | { readonly kind: "forward"; readonly body: Buffer } + | { readonly kind: "deny"; readonly detail: string }; + +function forward(body: Buffer): FilterOutcome { + return { kind: "forward", body }; +} + +function denyBody(detail: string): FilterOutcome { + return { kind: "deny", detail }; +} + +function reserialize(value: unknown): FilterOutcome { + return forward(Buffer.from(JSON.stringify(value), "utf8")); +} + +function sameIdentifier(left: unknown, right: string | undefined): boolean { + return ( + typeof left === "string" && + right !== undefined && + left.toLowerCase() === right.toLowerCase() + ); +} + +function isCurrentProject(value: unknown, policy: ProxyPolicy): boolean { + return sameIdentifier(value, policy.project) || sameIdentifier(value, policy.project_id); +} + +function isCurrentRepository(value: unknown, policy: ProxyPolicy): boolean { + return ( + sameIdentifier(value, policy.repository) || + sameIdentifier(value, policy.repository_id) + ); +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** Extract the `value` array from an Azure DevOps list envelope. */ +function listValues(document: Record): unknown[] | undefined { + return Array.isArray(document.value) ? (document.value as unknown[]) : undefined; +} + +/** + * Apply the operation's response policy. + * + * `json` is the common case and passes the body through unchanged; the body was + * already size-bounded by the caller. Everything else either narrows the body + * or refuses it. + */ +export function filterResponse( + operation: Operation, + policy: ProxyPolicy, + body: Buffer, +): FilterOutcome { + const responsePolicy: ResponsePolicy = operation.response; + if (responsePolicy === "json") return forward(body); + + let document: unknown; + try { + document = JSON.parse(body.toString("utf8")); + } catch { + // A scope-validated operation whose body cannot be parsed cannot be + // validated, so it cannot be forwarded. + return denyBody("upstream response was not parseable JSON"); + } + + const record = asRecord(document); + if (record === undefined) { + return denyBody("upstream response was not a JSON object"); + } + + switch (responsePolicy) { + case "filter-projects": { + const values = listValues(record); + if (values === undefined) return denyBody("project list had no value array"); + const kept = values.filter((entry) => { + const project = asRecord(entry); + return ( + project !== undefined && + (isCurrentProject(project.name, policy) || isCurrentProject(project.id, policy)) + ); + }); + return reserialize({ count: kept.length, value: kept }); + } + + case "filter-resource-areas": { + const values = listValues(record); + if (values === undefined) return denyBody("resource area list had no value array"); + // A resource area whose locationUrl points outside the protected set + // would send the client — and therefore the next request — to a host this + // proxy does not police. Drop those rather than rewriting them. + const kept = values.filter((entry) => { + const area = asRecord(entry); + if (area === undefined) return false; + return isProtectedLocation(area.locationUrl); + }); + return reserialize({ count: kept.length, value: kept }); + } + + case "validate-project": { + // Work items report their project in `fields["System.TeamProject"]`; + // other org-level resources carry a nested `project` object. Accept + // either shape, and deny when neither is present — an unvalidatable + // response cannot be forwarded. + const nested = asRecord(record.project); + const fromFields = asRecord(record.fields)?.["System.TeamProject"]; + const candidates = [nested?.name, nested?.id, fromFields]; + if (!candidates.some((candidate) => isCurrentProject(candidate, policy))) { + return denyBody("resource belongs to a different project"); + } + return forward(body); + } + + case "validate-project-and-repository": { + const repository = asRecord(record.repository); + if (repository === undefined) { + return denyBody("response carried no repository to validate"); + } + const project = asRecord(repository.project); + if ( + !isCurrentProject(project?.name, policy) && + !isCurrentProject(project?.id, policy) + ) { + return denyBody("resource belongs to a different project"); + } + if ( + !isCurrentRepository(repository.name, policy) && + !isCurrentRepository(repository.id, policy) + ) { + return denyBody("resource belongs to a different repository"); + } + return forward(body); + } + + default: { + // A response policy added in Rust but not implemented here must not + // default to forwarding an unvalidated body. + const exhaustive: never = responsePolicy; + return denyBody(`unimplemented response policy ${String(exhaustive)}`); + } + } +} + +/** True when a discovery `locationUrl` resolves to a protected host. */ +export function isProtectedLocation(locationUrl: unknown): boolean { + if (typeof locationUrl !== "string") return false; + let host: string; + try { + host = new URL(locationUrl).hostname.toLowerCase(); + } catch { + return false; + } + return PROTECTED_HOSTS.some((protectedHost) => protectedHost.toLowerCase() === host); +} diff --git a/scripts/ado-script/src/ado-proxy/route.test.ts b/scripts/ado-script/src/ado-proxy/route.test.ts new file mode 100644 index 000000000..788ec25da --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/route.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; + +import { DENIED_ROUTE_FAMILIES } from "./catalog.js"; +import { matchRoute, matchesDeniedFamily, normalizeTarget, NormalizeError } from "./route.js"; + +describe("normalizeTarget", () => { + it("splits a plain path into decoded segments", () => { + const target = normalizeTarget("/myorg/_apis/projects/MyProject"); + expect(target.segments).toEqual(["myorg", "_apis", "projects", "MyProject"]); + expect(target.query).toEqual([]); + }); + + it("decodes a segment exactly once", () => { + expect(normalizeTarget("/org/_apis/projects/My%20Project").segments).toEqual([ + "org", + "_apis", + "projects", + "My Project", + ]); + }); + + it("tolerates a single trailing slash", () => { + expect(normalizeTarget("/myorg/_apis/").segments).toEqual(["myorg", "_apis"]); + }); + + it("parses the query preserving order and duplicates", () => { + // Collapsing duplicates would hide exactly the conflict the api-version + // check needs to see. + expect(normalizeTarget("/o/_apis?a=1&a=2&b=").query).toEqual([ + ["a", "1"], + ["a", "2"], + ["b", ""], + ]); + }); + + it("rejects an encoded path separator", () => { + // `%2f` would let one segment masquerade as several, so a route the policy + // believes is bounded could address something else upstream. + expect(() => normalizeTarget("/org/_apis/projects/a%2f..%2fb")).toThrow( + NormalizeError, + ); + }); + + it("rejects double encoding", () => { + // `%252e` decodes to `%2e` here but to `.` upstream — the two would + // disagree about what path was authorized. + expect(() => normalizeTarget("/org/_apis/%252e%252e")).toThrow(NormalizeError); + }); + + it("rejects traversal, empty segments, and non-origin-form targets", () => { + expect(() => normalizeTarget("/org/../admin")).toThrow(NormalizeError); + expect(() => normalizeTarget("/org//admin")).toThrow(NormalizeError); + expect(() => normalizeTarget("https://dev.azure.com/org")).toThrow(NormalizeError); + expect(() => normalizeTarget("/org/_apis#frag")).toThrow(NormalizeError); + }); + + it("rejects control characters in a segment", () => { + expect(() => normalizeTarget("/org/_apis/pro%00ject")).toThrow(NormalizeError); + }); +}); + +describe("matchRoute", () => { + it("matches literals case-insensitively and captures placeholders", () => { + const params = matchRoute("/{org}/_apis/projects/{project}", [ + "myorg", + "_APIS", + "Projects", + "MyProject", + ]); + expect(params).toEqual({ org: "myorg", project: "MyProject" }); + }); + + it("requires an exact segment count", () => { + expect(matchRoute("/{org}/_apis", ["myorg"])).toBeUndefined(); + expect(matchRoute("/{org}/_apis", ["myorg", "_apis", "extra"])).toBeUndefined(); + }); + + it("enforces the shape of numeric id placeholders", () => { + const route = "/{org}/_apis/wit/workitems/{id}"; + expect(matchRoute(route, ["o", "_apis", "wit", "workitems", "42"])).toEqual({ + org: "o", + id: "42", + }); + // A non-numeric id would smuggle a sub-resource or filter into a route the + // catalog treats as fully bounded. + for (const bad of ["42;x", "0", "abc", "42?x", "-1"]) { + expect(matchRoute(route, ["o", "_apis", "wit", "workitems", bad])).toBeUndefined(); + } + }); + + it("enforces the shape of GUID and commit placeholders", () => { + expect( + matchRoute("/_apis/resourceareas/{areaId}", ["_apis", "resourceareas", "not-a-guid"]), + ).toBeUndefined(); + expect( + matchRoute("/_apis/resourceareas/{areaId}", [ + "_apis", + "resourceareas", + "79134c72-4a58-4b42-976c-04e7115f32bf", + ]), + ).toEqual({ areaId: "79134c72-4a58-4b42-976c-04e7115f32bf" }); + }); + + it("does not constrain scope placeholders by shape", () => { + // `org`, `project`, and `repository` are checked against the pinned policy + // values instead, which is strictly stronger than a regex. + expect(matchRoute("/{org}/_apis", ["Contoso Org", "_apis"])).toEqual({ + org: "Contoso Org", + }); + }); +}); + +describe("matchesDeniedFamily", () => { + it("finds a denied family anywhere in the path", () => { + expect( + matchesDeniedFamily( + ["org", "_apis", "serviceendpoint", "endpoints"], + ["/_apis/serviceendpoint"], + ), + ).toBe("/_apis/serviceendpoint"); + }); + + it("matches case-insensitively", () => { + expect(matchesDeniedFamily(["org", "project", "_GIT", "repo"], ["/_git/"])).toBe( + "/_git/", + ); + }); + + it("matches families that contain placeholders", () => { + // A substring test cannot match these: the literal text + // `{buildId}` never appears in a real path, so the denial would be + // silently inert exactly where defence-in-depth matters most. + for (const [path, family] of [ + [ + ["org", "proj", "_apis", "build", "builds", "42", "oauthtoken"], + "/_apis/build/builds/{buildId}/oauthtoken", + ], + [ + ["org", "proj", "_apis", "build", "builds", "42", "artifacts"], + "/_apis/build/builds/{buildId}/artifacts", + ], + [ + ["org", "proj", "_apis", "git", "repositories", "repo", "blobs"], + "/_apis/git/repositories/{repository}/blobs", + ], + ] as [string[], string][]) { + expect(matchesDeniedFamily(path, [family])).toBe(family); + } + }); + + it("matches a family that pins a query parameter", () => { + const family = "/_apis/wit/workitems?ids="; + expect( + matchesDeniedFamily(["org", "_apis", "wit", "workitems"], [family], [ + ["ids", "1,2,3"], + ]), + ).toBe(family); + // Without the pinned parameter the family does not apply, so the ordinary + // single-work-item route stays reachable. + expect( + matchesDeniedFamily(["org", "_apis", "wit", "workitems"], [family], []), + ).toBeUndefined(); + }); + + it("requires the family segments to be contiguous", () => { + expect( + matchesDeniedFamily( + ["org", "_apis", "build", "builds", "42", "extra", "artifacts"], + ["/_apis/build/builds/{buildId}/artifacts"], + ), + ).toBeUndefined(); + }); + + it("does not match a segment by prefix", () => { + // `serviceendpointproxy` is a different route; matching it here would be a + // false denial, and matching `_gitignore` for `/_git/` would be worse. + expect( + matchesDeniedFamily(["org", "_apis", "serviceendpointproxy"], [ + "/_apis/serviceendpoint", + ]), + ).toBeUndefined(); + }); + + it("returns undefined for an unrelated path", () => { + expect( + matchesDeniedFamily(["org", "_apis", "projects"], ["/_apis/serviceendpoint"]), + ).toBeUndefined(); + }); + + it("keeps every catalogued family expressible", () => { + // Guards the inverse failure: a family the matcher can never match is a + // denial the catalog author believes exists but nothing enforces. + for (const family of DENIED_ROUTE_FAMILIES) { + const segments = family + .split("?")[0] + ?.split("/") + .filter((part) => part !== "") + .map((part) => (part.startsWith("{") ? "placeholder" : part)) as string[]; + const query = family.includes("?") + ? ([[family.split("?")[1]?.split("=")[0] ?? "", "x"]] as [string, string][]) + : []; + expect( + matchesDeniedFamily(segments, [family], query), + `family ${family} is unmatchable`, + ).toBe(family); + } + }); +}); diff --git a/scripts/ado-script/src/ado-proxy/route.ts b/scripts/ado-script/src/ado-proxy/route.ts new file mode 100644 index 000000000..f5314afa8 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/route.ts @@ -0,0 +1,252 @@ +/** + * Request-target normalization and catalog route matching. + * + * Everything downstream — capability checks, scope checks, response filtering — + * keys off the normalized form produced here, so this module is the single + * place where an attacker could smuggle a different effective path past the + * policy. It therefore decodes exactly once and rejects anything ambiguous + * rather than trying to be lenient. + */ + +/** A request path split into decoded segments, plus its raw query string. */ +export interface NormalizedTarget { + /** Decoded, non-empty path segments. `/a/b` becomes `["a", "b"]`. */ + readonly segments: readonly string[]; + /** Parsed query parameters, preserving order and duplicates. */ + readonly query: readonly (readonly [string, string])[]; +} + +export class NormalizeError extends Error {} + +/** + * Characters that must never survive decoding inside a single path segment. + * + * A decoded `/` or `\` would mean the client encoded a separator to make one + * segment look like several; a decoded `%` means the value was encoded twice + * and would decode differently upstream than it does here; control characters + * are request-smuggling material. + */ +function rejectDangerousDecoded(segment: string, raw: string): void { + if (segment.includes("/") || segment.includes("\\")) { + throw new NormalizeError(`path segment decodes to a separator: ${raw}`); + } + if (segment.includes("%")) { + throw new NormalizeError(`path segment is doubly encoded: ${raw}`); + } + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u001f\u007f]/.test(segment)) { + throw new NormalizeError(`path segment contains a control character: ${raw}`); + } +} + +function decodeSegment(raw: string): string { + let decoded: string; + try { + decoded = decodeURIComponent(raw); + } catch { + throw new NormalizeError(`path segment is not valid percent-encoding: ${raw}`); + } + rejectDangerousDecoded(decoded, raw); + return decoded; +} + +/** + * Normalize an origin-form request target (`/path?query`). + * + * Rejects: absolute-form targets, traversal segments, empty segments, and + * anything that decodes ambiguously. There is no path *rewriting* here — a + * target that would need normalizing to become safe is refused instead, so the + * bytes the policy inspects are the bytes the upstream receives. + */ +export function normalizeTarget(target: string): NormalizedTarget { + if (!target.startsWith("/")) { + throw new NormalizeError(`request target must be origin-form, got ${target}`); + } + if (target.includes("#")) { + throw new NormalizeError("request target must not contain a fragment"); + } + + const split = target.indexOf("?"); + const rawPath = split === -1 ? target : target.slice(0, split); + const rawQuery = split === -1 ? "" : target.slice(split + 1); + + if (rawPath.includes("//")) { + throw new NormalizeError("request path contains an empty segment"); + } + + const rawSegments = rawPath.split("/").slice(1); + // A single trailing slash is idiomatic and harmless; drop it before the + // empty-segment check so `/org/_apis/` matches `/{org}/_apis`. + if (rawSegments.length > 1 && rawSegments[rawSegments.length - 1] === "") { + rawSegments.pop(); + } + + const segments = rawSegments.map((raw) => { + if (raw === "") { + throw new NormalizeError("request path contains an empty segment"); + } + const decoded = decodeSegment(raw); + if (decoded === "." || decoded === "..") { + throw new NormalizeError("request path contains a traversal segment"); + } + return decoded; + }); + + return { segments, query: parseQuery(rawQuery) }; +} + +/** + * Parse a query string into ordered pairs. + * + * Duplicates are preserved rather than collapsed: `api-version=7.1&api-version=1.0` + * must be visible to the policy as a conflict, not silently reduced to one + * value that may differ from the one the upstream honours. + */ +export function parseQuery(raw: string): (readonly [string, string])[] { + if (raw === "") return []; + return raw.split("&").map((pair) => { + if (pair === "") { + throw new NormalizeError("query string contains an empty parameter"); + } + const equals = pair.indexOf("="); + const rawName = equals === -1 ? pair : pair.slice(0, equals); + const rawValue = equals === -1 ? "" : pair.slice(equals + 1); + return [decodeQueryPart(rawName), decodeQueryPart(rawValue)] as const; + }); +} + +function decodeQueryPart(raw: string): string { + try { + return decodeURIComponent(raw.replace(/\+/g, " ")); + } catch { + throw new NormalizeError(`query part is not valid percent-encoding: ${raw}`); + } +} + +/** Placeholder values captured while matching a route template. */ +export type RouteParams = Readonly>; + +const PLACEHOLDER = /^\{([A-Za-z]+)\}$/; + +/** + * Per-placeholder value shape. + * + * Constraining these keeps a numeric id from carrying a path-like or + * filter-like payload into a route the catalog believes is fully bounded. + * Placeholders that name a *scope* (`org`, `project`, `repository`) are + * deliberately absent: they are checked against the policy's own values, which + * is strictly stronger than a shape check. + */ +const PLACEHOLDER_SHAPE: Readonly> = { + area: /^[A-Za-z][A-Za-z0-9._-]{0,63}$/, + areaId: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, + commitId: /^[0-9a-fA-F]{7,40}$/, + id: /^[1-9][0-9]{0,17}$/, + buildId: /^[1-9][0-9]{0,17}$/, + definitionId: /^[1-9][0-9]{0,17}$/, + pipelineId: /^[1-9][0-9]{0,17}$/, + pullRequestId: /^[1-9][0-9]{0,17}$/, + runId: /^[1-9][0-9]{0,17}$/, +}; + +/** + * Match normalized path segments against a catalog route template. + * + * Returns the captured placeholders, or `undefined` when the route does not + * apply. Literal segments compare case-insensitively because Azure DevOps + * routes are case-insensitive and clients vary (`_apis/wit` vs `_apis/WIT`). + */ +export function matchRoute( + route: string, + segments: readonly string[], +): RouteParams | undefined { + const template = route.split("/").slice(1); + if (template.length !== segments.length) return undefined; + + const params: Record = {}; + for (let index = 0; index < template.length; index += 1) { + const expected = template[index] as string; + const actual = segments[index] as string; + const placeholder = PLACEHOLDER.exec(expected); + + if (placeholder === null) { + if (expected.toLowerCase() !== actual.toLowerCase()) return undefined; + continue; + } + + const name = placeholder[1] as string; + if (actual === "") return undefined; + const shape = PLACEHOLDER_SHAPE[name]; + if (shape !== undefined && !shape.test(actual)) return undefined; + params[name] = actual; + } + return params; +} + +/** + * True when the request falls in an always-denied route family. + * + * Families are matched **structurally**, not by substring: the family is split + * into segments, `{placeholder}` matches any one segment, and the sequence must + * appear contiguously in the request path. A substring test cannot work, + * because the catalog authors families such as + * `/_apis/build/builds/{buildId}/oauthtoken` — the literal text never appears + * in a real path, so the denial would be silently inert. + * + * A family may also pin a query parameter with a `?name=` suffix + * (`/_apis/wit/workitems?ids=`), which is why the query is an input here. + * + * Checked before capability and route matching so a denied family can never be + * reached by a route that happens to look allowable, and so the denial reason + * reported to the author names the family rather than "unknown route". + */ +export function matchesDeniedFamily( + segments: readonly string[], + families: readonly string[], + query: readonly (readonly [string, string])[] = [], +): string | undefined { + const lowerSegments = segments.map((segment) => segment.toLowerCase()); + const queryNames = new Set(query.map(([name]) => name.toLowerCase())); + + return families.find((family) => { + const [pathPart, queryPart] = splitFamily(family); + if (queryPart !== undefined && !queryNames.has(queryPart)) return false; + return containsSegmentRun(lowerSegments, pathPart); + }); +} + +/** Split `/a/b?name=` into its segment list and the pinned query name. */ +function splitFamily(family: string): [readonly string[], string | undefined] { + const question = family.indexOf("?"); + const path = question === -1 ? family : family.slice(0, question); + const rawQuery = question === -1 ? undefined : family.slice(question + 1); + const queryName = + rawQuery === undefined ? undefined : rawQuery.split("=")[0]?.toLowerCase(); + const parts = path + .split("/") + .filter((part) => part !== "") + .map((part) => part.toLowerCase()); + return [parts, queryName === "" ? undefined : queryName]; +} + +/** True when `family` appears as a contiguous run of `segments`. */ +function containsSegmentRun( + segments: readonly string[], + family: readonly string[], +): boolean { + if (family.length === 0) return false; + for (let start = 0; start + family.length <= segments.length; start += 1) { + let matched = true; + for (let offset = 0; offset < family.length; offset += 1) { + const expected = family[offset] as string; + // `{placeholder}` stands for exactly one segment of any value. + if (expected.startsWith("{") && expected.endsWith("}")) continue; + if (expected !== segments[start + offset]) { + matched = false; + break; + } + } + if (matched) return true; + } + return false; +} diff --git a/scripts/ado-script/src/ado-proxy/server.ts b/scripts/ado-script/src/ado-proxy/server.ts new file mode 100644 index 000000000..12009976e --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/server.ts @@ -0,0 +1,542 @@ +/** + * The proxy server: two paths, chosen by destination host. + * + * - **Not protected** — CONNECT straight through Squid and byte-tunnel. No + * TLS termination, no parsing, no header rewriting, so package feeds, model + * endpoints, and every other allowed host behave exactly as they do without + * this sidecar in the chain. + * - **Protected** — terminate TLS with the ephemeral CA, authorize against the + * catalog, strip client credentials, inject the bearer, forward through + * Squid, and filter the response. + * + * The proxy is deliberately safe when reached *directly* by the agent rather + * than via Squid: AWF's internal network makes all peers mutually reachable, so + * source address is not an authorization input. Policy is identical either way, + * and there is no generic relay — an unprotected destination is tunnelled to + * Squid, which applies its own domain policy, rather than dialled directly. + */ +import { randomUUID } from "node:crypto"; +import { createServer as createHttpServer, type IncomingMessage, type ServerResponse, request as httpRequest } from "node:http"; +import type { Server } from "node:http"; +import type { Socket } from "node:net"; +import { connect as tlsConnect, createSecureContext, createServer as createTlsServer, type TLSSocket } from "node:tls"; + +import type { CaMaterials } from "./ca.js"; +import { canonicalizeHost, isProtectedHost } from "./catalog.js"; +import type { ProxyConfig } from "./config.js"; +import { sanitizeRequestHeaders, sanitizeResponseHeaders } from "./headers.js"; +import { DecisionLog, statusClass, type DecisionRecord } from "./log.js"; +import { authorize } from "./policy.js"; +import { filterResponse } from "./response.js"; +import { TokenError, TokenSource, bearerHeader } from "./token.js"; +import { NormalizeError, normalizeTarget } from "./route.js"; +import { connectThroughProxy, parseUpstreamProxy } from "./upstream.js"; + +/** Status returned for a policy denial. */ +const DENY_STATUS = 403; +/** + * Status returned when the proxy itself is broken (no token, upstream refused). + * + * Deliberately *not* 401, 429, or 503: `msrest` — which `az devops` uses — + * retries those, turning one denied call into several and, for a semantic POST, + * risking repeated side effects upstream. 502 is terminal for every client in + * the supported set. + */ +const INFRA_STATUS = 502; + +/** + * Origin-form readiness path. + * + * AWF polls this before starting the agent, so the agent cannot race a proxy + * that has not finished minting its CA. It is the only origin-form request the + * proxy answers; everything else on that shape is a relay attempt. + */ +export const HEALTH_PATH = "/_ado-proxy/healthz"; + +/** + * Cap on a single upstream request. + * + * A hung Azure DevOps connection would otherwise hold the agent's request — and + * a socket — indefinitely, which reads to the agent as a stall rather than a + * failure it can report. + */ +const UPSTREAM_TIMEOUT_MS = 120_000; + +export interface ProxyDeps { + readonly config: ProxyConfig; + readonly ca: CaMaterials; + readonly tokens: TokenSource; + readonly log: DecisionLog; + /** + * Extra CA used when verifying the *upstream* Azure DevOps certificate. + * + * Only integration tests set this, to point the proxy at a fake upstream. + * `rejectUnauthorized` stays on either way, so this narrows what the proxy + * trusts rather than disabling verification. + */ + readonly upstreamCa?: string; +} + +/** Send a small JSON error body that no supported client will retry. */ +function respondError( + response: ServerResponse, + status: number, + reason: string, + detail: string, +): void { + // Azure DevOps `WrappedException` shape. `az` and every msrest-based SDK read + // `message` when a call fails, so a denial surfaces as an actionable sentence + // rather than "unexpected response". + const body = JSON.stringify({ + $id: "1", + innerException: null, + message: `ado-proxy: ${detail}`, + typeName: `AdoProxy.${reason}, ado-proxy`, + typeKey: reason, + errorCode: 0, + eventId: 0, + }); + response.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(body), + connection: "close", + }); + response.end(body); +} + +function hostAndPort(authority: string): { host: string; port: number } { + const lastColon = authority.lastIndexOf(":"); + if (lastColon === -1 || authority.endsWith("]")) { + return { host: canonicalizeHost(authority), port: 443 }; + } + const port = Number(authority.slice(lastColon + 1)); + return { + host: canonicalizeHost(authority.slice(0, lastColon)), + port: Number.isInteger(port) && port > 0 ? port : 443, + }; +} + +/** + * Forward an absolute-form plain HTTP request to Squid. + * + * The agent's `HTTP_PROXY` points here, so *all* cleartext traffic arrives at + * this handler — including `http://` package sources. Refusing it would be a + * silent network regression, so it is relayed to Squid verbatim and Squid's own + * domain policy decides. Nothing is inspected and no credential is added: + * cleartext is never a protected path. + */ +function forwardPlainHttp( + deps: ProxyDeps, + request: IncomingMessage, + response: ServerResponse, +): void { + const proxy = parseUpstreamProxy(deps.config.upstreamProxy); + const headers: Record = {}; + for (const [name, value] of Object.entries(request.headers)) { + // `connection` and friends are hop-by-hop; forwarding them would confuse + // Squid about the lifetime of its own socket. + if (["connection", "proxy-connection", "keep-alive"].includes(name.toLowerCase())) { + continue; + } + if (value !== undefined) headers[name] = value; + } + + const upstream = httpRequest({ + host: proxy.host, + port: proxy.port, + method: request.method, + // Absolute-form target: this is exactly what a client with `HTTP_PROXY` + // set would have sent to Squid directly. + path: request.url ?? "/", + headers, + }); + + upstream.on("response", (upstreamResponse) => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }); + upstream.on("error", () => { + if (!response.headersSent) { + respondError(response, INFRA_STATUS, "upstream-failed", "the upstream proxy failed"); + return; + } + response.destroy(); + }); + request.pipe(upstream); +} + +async function tunnel( + deps: ProxyDeps, + clientSocket: Socket, + head: Buffer, + host: string, + port: number, +): Promise { + const proxy = parseUpstreamProxy(deps.config.upstreamProxy); + try { + const upstream = await connectThroughProxy(proxy, host, port); + if (clientSocket.destroyed) { + // The client gave up while we were dialling Squid; do not leave the + // upstream socket dangling. + upstream.destroy(); + return; + } + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) upstream.write(head); + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + const destroyBoth = (): void => { + upstream.destroy(); + clientSocket.destroy(); + }; + upstream.on("error", destroyBoth); + clientSocket.on("error", destroyBoth); + clientSocket.on("close", destroyBoth); + } catch (error) { + // Mirror Squid's own refusal shape rather than inventing one; the client + // sees the same failure it would see talking to Squid directly. + if (!clientSocket.destroyed) clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + deps.log.write({ + ts: new Date().toISOString(), + request_id: randomUUID(), + host, + method: "CONNECT", + decision: "error", + reason: "tunnel-failed", + detail: (error as Error).message, + }); + } +} + +/** Read a bounded response body, destroying the stream if the cap is passed. */ +function readBounded(stream: IncomingMessage, limit: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + stream.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > limit) { + stream.destroy(); + reject(new Error(`upstream response exceeded ${limit} bytes`)); + return; + } + chunks.push(chunk); + }); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", reject); + }); +} + +/** + * Handle one intercepted request to a protected host. + * + * Order is the security contract: normalize, authorize, *then* read the token. + * A denial therefore never touches the credential, and no code path can emit + * the bearer for a request that was not fully approved. + */ +async function handleProtected( + deps: ProxyDeps, + host: string, + request: IncomingMessage, + response: ServerResponse, +): Promise { + const started = Date.now(); + const requestId = randomUUID(); + const method = request.method ?? "GET"; + const base: Omit = { + ts: new Date().toISOString(), + request_id: requestId, + host, + method, + }; + + let target; + try { + target = normalizeTarget(request.url ?? "/"); + } catch (error) { + if (!(error instanceof NormalizeError)) throw error; + deps.log.write({ ...base, decision: "deny", reason: "malformed-target", detail: error.message }); + respondError(response, DENY_STATUS, "malformed-target", error.message); + return; + } + + const accept = request.headers.accept; + const decision = authorize( + { method, host, target, accept: Array.isArray(accept) ? accept[0] : accept }, + deps.config.policy, + ); + + if (!decision.allow) { + deps.log.write({ + ...base, + decision: "deny", + reason: decision.reason, + detail: decision.detail, + ...(decision.operationId === undefined ? {} : { operation: decision.operationId }), + }); + respondError(response, DENY_STATUS, decision.reason, decision.detail); + return; + } + + let token: string; + try { + token = deps.tokens.read(); + } catch (error) { + if (!(error instanceof TokenError)) throw error; + deps.log.write({ + ...base, + operation: decision.operation.id, + decision: "error", + reason: "credential-unavailable", + detail: "the proxy has no current Azure DevOps token", + }); + // Never forward an authorized request unauthenticated: Azure DevOps would + // answer with a sign-in page that clients cannot distinguish from data. + respondError( + response, + INFRA_STATUS, + "credential-unavailable", + "the ado-proxy has no current Azure DevOps credential", + ); + return; + } + + const { headers, strippedCredentials } = sanitizeRequestHeaders(request.headers, host); + // The single point at which the real credential enters a request. It is + // applied to a *copy*, after the allow decision and after the token read, so + // no earlier code path can observe or emit it. + const upstreamHeaders: Record = { + ...headers, + authorization: bearerHeader(token), + }; + + const proxy = parseUpstreamProxy(deps.config.upstreamProxy); + let secured: TLSSocket | undefined; + try { + const raw = await connectThroughProxy(proxy, host, 443); + secured = tlsConnect({ + socket: raw, + servername: host, + ...(deps.upstreamCa === undefined ? {} : { ca: deps.upstreamCa }), + }); + const upstreamSocket = secured; + await new Promise((resolve, reject) => { + upstreamSocket.once("secureConnect", () => resolve()); + upstreamSocket.once("error", reject); + }); + // A late socket error — after the handshake, or after the response has been + // read — would otherwise be an unhandled 'error' event and crash the proxy, + // taking down the agent's only route to Azure DevOps. + upstreamSocket.on("error", () => upstreamSocket.destroy()); + + const upstream = httpRequest({ + // No `agent` key at all: Node only honours `createConnection` when the + // agent is left undefined. Setting `agent: false` makes it construct a + // default agent, which would ignore this socket and dial localhost. + createConnection: () => upstreamSocket, + method, + path: request.url ?? "/", + headers: upstreamHeaders, + }); + upstream.setTimeout(UPSTREAM_TIMEOUT_MS, () => { + upstream.destroy(new Error("upstream request timed out")); + }); + + const upstreamResponse = await new Promise((resolve, reject) => { + upstream.once("response", resolve); + upstream.once("error", reject); + upstream.end(); + }); + upstream.on("error", () => upstream.destroy()); + + const body = await readBounded(upstreamResponse, decision.operation.max_response_bytes); + const status = upstreamResponse.statusCode ?? 502; + const outcome = filterResponse(decision.operation, deps.config.policy, body); + + if (outcome.kind === "deny") { + deps.log.write({ + ...base, + operation: decision.operation.id, + decision: "deny", + reason: "out-of-scope-response", + detail: outcome.detail, + upstream_status_class: statusClass(status), + latency_ms: Date.now() - started, + }); + respondError(response, DENY_STATUS, "out-of-scope-response", outcome.detail); + return; + } + + const responseHeaders = sanitizeResponseHeaders(upstreamResponse.headers); + responseHeaders["content-length"] = String(outcome.body.length); + responseHeaders.connection = "close"; + response.writeHead(status, responseHeaders); + response.end(outcome.body); + + deps.log.write({ + ...base, + operation: decision.operation.id, + decision: "allow", + upstream_status_class: statusClass(status), + latency_ms: Date.now() - started, + response_bytes: outcome.body.length, + ...(strippedCredentials.length === 0 + ? {} + : { stripped_credentials: strippedCredentials }), + }); + } catch (error) { + secured?.destroy(); + deps.log.write({ + ...base, + operation: decision.operation.id, + decision: "error", + reason: "upstream-failed", + detail: (error as Error).message, + latency_ms: Date.now() - started, + }); + if (!response.headersSent) { + respondError(response, INFRA_STATUS, "upstream-failed", "the upstream request failed"); + return; + } + // The body was already committed; the only safe move is to cut the + // connection rather than append anything to a partially sent response. + response.destroy(); + } +} + +/** + * Build the intercepting HTTPS front end. + * + * One TLS server serves every protected host, selecting the right leaf by SNI. + * Sockets are handed to an inner HTTP server so Node parses the tunnelled + * requests for us. + */ +function createInterceptor(deps: ProxyDeps): (socket: Socket, host: string) => void { + const inner = createHttpServer((request, response) => { + const socket = request.socket as TLSSocket; + const host = canonicalizeHost(socket.servername || ""); + void handleProtected(deps, host, request, response).catch(() => { + if (!response.headersSent) { + respondError(response, INFRA_STATUS, "internal-error", "the proxy failed"); + } + }); + }); + + const tls = createTlsServer({ + // HTTP/1.1 only. The inner server is an `http.Server`, which cannot parse + // an h2 stream; without pinning ALPN a modern client would negotiate h2 and + // then talk a protocol nothing here understands. + ALPNProtocols: ["http/1.1"], + SNICallback: (servername, callback) => { + const leaf = deps.ca.leaves.get(canonicalizeHost(servername)); + if (leaf === undefined) { + // Only compiler-pinned protected hosts have leaves. Anything else + // reaching the interceptor is a mismatch between the CONNECT target and + // the SNI, which is a smuggling attempt, not a supported client. + callback(new Error(`no certificate for ${servername}`)); + return; + } + callback(null, createSecureContext({ key: leaf.key, cert: leaf.cert })); + }, + }); + tls.on("secureConnection", (socket) => inner.emit("connection", socket)); + + return (socket: Socket, host: string) => { + if (!deps.ca.leaves.has(host)) { + socket.destroy(); + return; + } + tls.emit("connection", socket); + }; +} + +/** Create the listening proxy server. */ +export function createProxyServer(deps: ProxyDeps): Server { + const intercept = createInterceptor(deps); + + const server = createHttpServer((request, response) => { + const target = request.url ?? ""; + + if (target === HEALTH_PATH) { + // AWF waits for this before starting the agent, so that the agent never + // races a proxy that has not yet minted its CA. Deliberately reveals no + // policy detail. + const body = JSON.stringify({ status: "ok" }); + response.writeHead(200, { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + connection: "close", + }); + response.end(body); + return; + } + + if (!target.startsWith("http://")) { + // Origin-form on the proxy port means someone addressed the proxy itself + // rather than asking it to reach a destination. There is nothing here to + // serve, and answering would make this look like a generic relay. + respondError( + response, + DENY_STATUS, + "relay-denied", + "this proxy serves absolute-form requests and CONNECT only", + ); + return; + } + + let host: string; + try { + host = canonicalizeHost(new URL(target).hostname); + } catch { + respondError(response, DENY_STATUS, "malformed-target", "unparseable request target"); + return; + } + + if (isProtectedHost(host)) { + // Azure DevOps is HTTPS-only. A cleartext request to a protected host is + // a downgrade attempt, and relaying it would put the policy path — and + // therefore the bearer — on an unencrypted hop. + respondError( + response, + DENY_STATUS, + "cleartext-denied", + "protected Azure DevOps hosts must be reached over HTTPS", + ); + return; + } + + forwardPlainHttp(deps, request, response); + }); + + server.on("connect", (request, socket: Socket, head: Buffer) => { + // Node removes its own `error` listener from the socket before emitting + // `connect`, and `clientError` does not cover sockets it has handed off. + // Without this, a client that resets the connection while we are still + // dialling Squid produces an uncaught exception and kills the sidecar — + // the agent's only route to Azure DevOps. + socket.on("error", () => socket.destroy()); + + const { host, port } = hostAndPort(request.url ?? ""); + if (isProtectedHost(host)) { + if (port !== 443) { + // Azure DevOps serves REST on 443 only. A protected host on another + // port would skip interception here and land on Squid's generic + // domain policy, which is not scoped to the catalog. + socket.end("HTTP/1.1 403 Forbidden\r\n\r\n"); + return; + } + if (head.length > 0) socket.unshift(head); + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + intercept(socket, host); + return; + } + void tunnel(deps, socket, head, host, port); + }); + + // A client that vanishes mid-handshake must not take the proxy down with it. + server.on("clientError", (_error, socket) => { + (socket as Socket).destroy(); + }); + + return server; +} diff --git a/scripts/ado-script/src/ado-proxy/token.test.ts b/scripts/ado-script/src/ado-proxy/token.test.ts new file mode 100644 index 000000000..3b4d02858 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/token.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { TokenError, TokenSource, bearerHeader } from "./token.js"; + +let directory: string; +let path: string; + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "ado-proxy-token-")); + path = join(directory, "token"); +}); + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }); +}); + +describe("TokenSource", () => { + it("reads and trims the token", () => { + writeFileSync(path, " abc123\n"); + expect(new TokenSource(path).read()).toBe("abc123"); + }); + + it("throws when the file is missing", () => { + // Never returns undefined: an unauthenticated forward would be answered by + // Azure DevOps with a sign-in page the agent could mistake for data. + expect(() => new TokenSource(path).read()).toThrow(TokenError); + }); + + it("throws when the file is empty or whitespace", () => { + writeFileSync(path, " \n"); + expect(() => new TokenSource(path).read()).toThrow(TokenError); + }); + + it("picks up a rotated token", () => { + writeFileSync(path, "first"); + const source = new TokenSource(path); + expect(source.read()).toBe("first"); + // Same length as "first" would leave size unchanged, so this also exercises + // the mtime half of the cache key. + writeFileSync(path, "secnd"); + expect(source.read()).toBe("secnd"); + }); + + it("stops serving a cached token once the file disappears", () => { + writeFileSync(path, "first"); + const source = new TokenSource(path); + expect(source.read()).toBe("first"); + rmSync(path); + expect(() => source.read()).toThrow(TokenError); + }); +}); + +describe("bearerHeader", () => { + it("formats an AAD access token as a bearer", () => { + expect(bearerHeader("abc")).toBe("Bearer abc"); + }); +}); diff --git a/scripts/ado-script/src/ado-proxy/token.ts b/scripts/ado-script/src/ado-proxy/token.ts new file mode 100644 index 000000000..3cb280b27 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/token.ts @@ -0,0 +1,93 @@ +/** + * Access to the Azure DevOps bearer. + * + * The token lives in a file the trusted host task rotates and mounts read-only + * into this container. It is deliberately *not* passed in argv or the + * environment: both are readable from the process table and from `/proc`, and + * neither can be rotated without restarting the proxy. + * + * Reads are cached on the file's mtime and size so the hot path does not stat- + * and-read per request, while a rotation still takes effect on the next + * request rather than at some later refresh tick. + */ +import { readFileSync, statSync } from "node:fs"; + +export class TokenError extends Error {} + +interface CachedToken { + readonly mtimeMs: number; + readonly size: number; + readonly value: string; +} + +export class TokenSource { + readonly #path: string; + #cached: CachedToken | undefined; + + constructor(path: string) { + this.#path = path; + } + + /** + * Return the current bearer. + * + * Throws {@link TokenError} when the file is missing, unreadable, or empty. + * Callers must translate that into an infrastructure failure — never into a + * request forwarded without credentials, which Azure DevOps would answer + * with a sign-in page the agent could mistake for data. + */ + read(): string { + let stats: ReturnType; + try { + stats = statSync(this.#path); + } catch (error) { + this.#cached = undefined; + throw new TokenError( + `token file ${this.#path} is unavailable: ${(error as Error).message}`, + ); + } + + const cached = this.#cached; + if ( + cached !== undefined && + cached.mtimeMs === stats.mtimeMs && + cached.size === stats.size + ) { + return cached.value; + } + + let raw: string; + try { + raw = readFileSync(this.#path, "utf8"); + } catch (error) { + this.#cached = undefined; + throw new TokenError( + `token file ${this.#path} is unreadable: ${(error as Error).message}`, + ); + } + + const value = raw.trim(); + if (value === "") { + this.#cached = undefined; + throw new TokenError(`token file ${this.#path} is empty`); + } + + this.#cached = { mtimeMs: stats.mtimeMs, size: stats.size, value }; + return value; + } + + /** Drop the cache. Used by tests and after an upstream 401. */ + invalidate(): void { + this.#cached = undefined; + } +} + +/** + * Build the `Authorization` header value for an authorized request. + * + * Azure DevOps accepts the AAD access token as a bearer; the sentinel PAT the + * agent may have supplied was already stripped by {@link sanitizeRequestHeaders}. + */ +export function bearerHeader(token: string): string { + return `Bearer ${token}`; +} diff --git a/scripts/ado-script/src/ado-proxy/upstream.ts b/scripts/ado-script/src/ado-proxy/upstream.ts new file mode 100644 index 000000000..302dfea60 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/upstream.ts @@ -0,0 +1,104 @@ +/** + * Egress through Squid. + * + * The sidecar has no direct internet route: Squid is the only dual-homed + * container on the AWF network, so every upstream connection — tunnelled or + * intercepted — starts with a CONNECT to Squid. That keeps Squid's existing + * domain policy and any configured global upstream proxy in force for traffic + * this proxy forwards, exactly as for traffic it never sees. + */ +import { connect as netConnect, type Socket } from "node:net"; + +export class UpstreamError extends Error {} + +interface SquidAddress { + readonly host: string; + readonly port: number; +} + +/** Parse the configured Squid URL into a host and port. */ +export function parseUpstreamProxy(raw: string): SquidAddress { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new UpstreamError(`upstream proxy ${JSON.stringify(raw)} is not a URL`); + } + if (url.protocol !== "http:") { + // An HTTPS-to-Squid hop would need its own trust configuration and buys + // nothing on an internal container network. + throw new UpstreamError(`upstream proxy must be http://, got ${url.protocol}`); + } + return { host: url.hostname, port: url.port === "" ? 3128 : Number(url.port) }; +} + +const CONNECT_TIMEOUT_MS = 30_000; + +/** + * Open a tunnel to `host:port` through Squid. + * + * Resolves with the raw socket once Squid answers `200`. A non-200 is surfaced + * as an {@link UpstreamError} carrying only the status line, because Squid's + * error bodies can echo request details. + */ +export function connectThroughProxy( + proxy: SquidAddress, + host: string, + port: number, +): Promise { + return new Promise((resolve, reject) => { + const socket = netConnect({ host: proxy.host, port: proxy.port }); + let settled = false; + let buffered = ""; + + const fail = (error: Error): void => { + if (settled) return; + settled = true; + socket.destroy(); + reject(error); + }; + + const timer = setTimeout(() => { + fail(new UpstreamError(`timed out opening a tunnel to ${host}:${port}`)); + }, CONNECT_TIMEOUT_MS); + timer.unref?.(); + + const onData = (chunk: Buffer): void => { + buffered += chunk.toString("latin1"); + const headerEnd = buffered.indexOf("\r\n\r\n"); + if (headerEnd === -1) { + if (buffered.length > 16 * 1024) { + fail(new UpstreamError("upstream proxy sent an oversized CONNECT response")); + } + return; + } + + const statusLine = buffered.slice(0, buffered.indexOf("\r\n")); + const status = Number(statusLine.split(" ")[1]); + if (status !== 200) { + fail(new UpstreamError(`upstream proxy refused CONNECT: ${statusLine.trim()}`)); + return; + } + + settled = true; + clearTimeout(timer); + socket.removeListener("data", onData); + socket.removeListener("error", fail); + + // Squid should not send payload bytes before the tunnel opens; if it did, + // they belong to the tunnelled stream and must not be dropped. + const leftover = buffered.slice(headerEnd + 4); + if (leftover.length > 0) socket.unshift(Buffer.from(leftover, "latin1")); + resolve(socket); + }; + + socket.on("error", fail); + socket.on("data", onData); + socket.on("connect", () => { + socket.write( + `CONNECT ${host}:${port} HTTP/1.1\r\nHost: ${host}:${port}\r\n` + + "Proxy-Connection: keep-alive\r\n\r\n", + ); + }); + }); +} diff --git a/scripts/ado-script/src/shared/ado-proxy-catalog.types.gen.ts b/scripts/ado-script/src/shared/ado-proxy-catalog.types.gen.ts new file mode 100644 index 000000000..65316279f --- /dev/null +++ b/scripts/ado-script/src/shared/ado-proxy-catalog.types.gen.ts @@ -0,0 +1,57 @@ +// AUTO-GENERATED from Rust via cargo run -- export-ado-proxy-catalog-schema. Do not edit; run npm run codegen. + +export type Capability = "discovery" | "core" | "repos" | "pipelines" | "boards"; +export type HostPolicy = "current-organization" | "sps-fallback"; +export type HttpMethod = "GET" | "OPTIONS"; +export type ResponsePolicy = + | "json" + | "filter-projects" + | "filter-resource-areas" + | "validate-project" + | "validate-project-and-repository"; +export type ScopePolicy = + | "current-organization" + | "allowed-resource-area" + | "current-project-path" + | "current-repository-path" + | "filter-projects-to-current" + | "filter-resource-areas" + | "response-current-project" + | "response-current-repository"; + +export interface Catalog { + /** + * Inclusive `[major, minor]` upper bound of the accepted REST API version. + * + * @minItems 2 + * @maxItems 2 + */ + api_version_max: [number, number]; + /** + * Inclusive `[major, minor]` lower bound of the accepted REST API version. + * + * @minItems 2 + * @maxItems 2 + */ + api_version_min: [number, number]; + denied_route_families: string[]; + operations: Operation[]; + protected_hosts: string[]; + runtime_available: boolean; + schema_version: string; + [k: string]: unknown; +} +export interface Operation { + allowed_query: string[]; + api_version: string; + capability: Capability; + denied_query: string[]; + host: HostPolicy; + id: string; + max_response_bytes: number; + method: HttpMethod; + response: ResponsePolicy; + route: string; + scope: ScopePolicy; + [k: string]: unknown; +} diff --git a/src/ado_proxy/catalog.rs b/src/ado_proxy/catalog.rs new file mode 100644 index 000000000..57782cac2 --- /dev/null +++ b/src/ado_proxy/catalog.rs @@ -0,0 +1,696 @@ +//! Versioned, deny-by-default Azure DevOps read-operation catalog. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub const CATALOG_SCHEMA_VERSION: &str = "ado-aw/ado-proxy-catalog/v1"; + +/// Whether authors can actually reach this catalog through a compiled pipeline. +/// +/// This gates the *author-facing* path, not the existence of the runtime: the +/// `ado-proxy` bundle is implemented and tested, but nothing emits its sidecar, +/// policy document, or credential lifecycle yet. It flips only once +/// `compiler-proxy-wiring` lands against a pinned AWF release whose agent image +/// supports the managed policy proxy and CA. +pub const RUNTIME_AVAILABLE: bool = false; + +/// Canonical Azure DevOps Services REST host for the current organization. +pub const ORGANIZATION_HOST: &str = "dev.azure.com"; + +/// Hardcoded deployment-level SPS host used for resource-area fallback +/// discovery. It is organization-agnostic, so its routes carry no `{org}` +/// segment and are scoped by allowed resource-area id instead. +pub const SPS_FALLBACK_HOST: &str = "app.vssps.visualstudio.com"; + +const JSON_LIMIT: u64 = 10 * 1024 * 1024; +const NO_QUERY: &[&str] = &[]; + +/// Marker used by every operation that negotiates a normal REST API version. +/// The concrete accepted range lives in [`API_VERSION_MIN`] / +/// [`API_VERSION_MAX`] so the catalog string and the enforcement code cannot +/// drift. +pub const API_VERSION_RANGE: &str = "5.0..=7.2; preview allowed"; + +/// Marker used by discovery `OPTIONS` operations, which must be sent without +/// any API version at all (in the query string or the `Accept` header). +pub const API_VERSION_ABSENT: &str = "absent"; + +/// Inclusive lower bound of the accepted `major.minor` API version. +pub const API_VERSION_MIN: (u32, u32) = (5, 0); + +/// Inclusive upper bound of the accepted `major.minor` API version. +pub const API_VERSION_MAX: (u32, u32) = (7, 2); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + Get, + Options, +} + +impl HttpMethod { + pub const fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Options => "OPTIONS", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum Capability { + Discovery, + Core, + Repos, + Pipelines, + Boards, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum HostPolicy { + CurrentOrganization, + SpsFallback, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum ScopePolicy { + CurrentOrganization, + AllowedResourceArea, + CurrentProjectPath, + CurrentRepositoryPath, + FilterProjectsToCurrent, + FilterResourceAreas, + ResponseCurrentProject, + ResponseCurrentRepository, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum ResponsePolicy { + Json, + FilterProjects, + FilterResourceAreas, + ValidateProject, + ValidateProjectAndRepository, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct Operation { + pub id: &'static str, + pub capability: Capability, + pub method: HttpMethod, + pub host: HostPolicy, + pub route: &'static str, + pub api_version: &'static str, + pub scope: ScopePolicy, + pub response: ResponsePolicy, + pub allowed_query: &'static [&'static str], + pub denied_query: &'static [&'static str], + pub max_response_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct Catalog { + pub schema_version: &'static str, + pub runtime_available: bool, + pub protected_hosts: &'static [&'static str], + pub operations: Vec, + pub denied_route_families: &'static [&'static str], + /// Inclusive `[major, minor]` lower bound of the accepted REST API version. + pub api_version_min: [u32; 2], + /// Inclusive `[major, minor]` upper bound of the accepted REST API version. + pub api_version_max: [u32; 2], +} + +/// Generate the JSON Schema for the `ado-proxy` catalog. +/// +/// This schema is the formal contract between the Rust compiler (which emits +/// the policy document) and the `ado-proxy` TypeScript bundle (which enforces +/// it). `npm run codegen` in `scripts/ado-script` turns it into +/// `src/shared/ado-proxy-catalog.types.gen.ts`, so the bundle cannot compile +/// against a stale shape. +/// +/// Mirrors [`crate::compile::filter_ir::generate_gate_spec_schema`]. +pub fn generate_catalog_schema() -> String { + let schema = schemars::schema_for!(Catalog); + serde_json::to_string_pretty(&schema).expect("catalog schema serialization") +} + +/// Generate the catalog **data** as JSON. +/// +/// Emitted to a committed `catalog.gen.json` by `npm run codegen` and +/// drift-checked in CI, so any Rust-side change to an operation, scope, +/// response policy, or denial family forces a regeneration rather than +/// silently diverging from what the sidecar enforces. +/// +/// Mirrors [`crate::compile::filter_ir::generate_fact_catalog`]. +pub fn generate_catalog_json() -> String { + serde_json::to_string_pretty(&catalog()).expect("catalog serialization") +} + +macro_rules! get { + ($id:literal, $cap:ident, $host:ident, $route:literal, $scope:ident, $response:ident, $query:expr) => { + Operation { + id: $id, + capability: Capability::$cap, + method: HttpMethod::Get, + host: HostPolicy::$host, + route: $route, + api_version: API_VERSION_RANGE, + scope: ScopePolicy::$scope, + response: ResponsePolicy::$response, + allowed_query: $query, + denied_query: NO_QUERY, + max_response_bytes: JSON_LIMIT, + } + }; +} + +pub fn catalog() -> Catalog { + let (min_major, min_minor) = API_VERSION_MIN; + let (max_major, max_minor) = API_VERSION_MAX; + Catalog { + schema_version: CATALOG_SCHEMA_VERSION, + runtime_available: RUNTIME_AVAILABLE, + protected_hosts: &[ORGANIZATION_HOST, SPS_FALLBACK_HOST], + operations: operations(), + denied_route_families: DENIED_ROUTE_FAMILIES, + api_version_min: [min_major, min_minor], + api_version_max: [max_major, max_minor], + } +} + +pub fn operations() -> Vec { + vec![ + Operation { + id: "discovery.host-options", + capability: Capability::Discovery, + method: HttpMethod::Options, + host: HostPolicy::CurrentOrganization, + route: "/{org}/_apis", + api_version: API_VERSION_ABSENT, + scope: ScopePolicy::CurrentOrganization, + response: ResponsePolicy::Json, + allowed_query: &["allHostTypes"], + denied_query: NO_QUERY, + max_response_bytes: JSON_LIMIT, + }, + Operation { + id: "discovery.area-options", + capability: Capability::Discovery, + method: HttpMethod::Options, + host: HostPolicy::CurrentOrganization, + route: "/{org}/_apis/{area}", + api_version: API_VERSION_ABSENT, + scope: ScopePolicy::CurrentOrganization, + response: ResponsePolicy::Json, + allowed_query: NO_QUERY, + denied_query: NO_QUERY, + max_response_bytes: JSON_LIMIT, + }, + get!( + "discovery.resource-areas", + Discovery, + CurrentOrganization, + "/{org}/_apis/resourceareas", + FilterResourceAreas, + FilterResourceAreas, + NO_QUERY + ), + get!( + "discovery.sps-resource-area", + Discovery, + SpsFallback, + "/_apis/resourceareas/{areaId}", + AllowedResourceArea, + Json, + NO_QUERY + ), + get!( + "discovery.connection-data", + Discovery, + CurrentOrganization, + "/{org}/_apis/connectiondata", + CurrentOrganization, + Json, + &["connectOptions", "lastChangeId", "lastChangeId64"] + ), + get!( + "core.project-get", + Core, + CurrentOrganization, + "/{org}/_apis/projects/{project}", + CurrentProjectPath, + Json, + &["includeCapabilities", "includeHistory"] + ), + get!( + "core.project-validation-probe", + Core, + CurrentOrganization, + "/{org}/_apis/projects", + FilterProjectsToCurrent, + FilterProjects, + &["stateFilter", "$top", "$skip"] + ), + get!( + "repos.repository-get", + Repos, + CurrentOrganization, + "/{org}/{project}/_apis/git/repositories/{repository}", + CurrentRepositoryPath, + Json, + &["includeParent"] + ), + get!( + "repos.refs-list", + Repos, + CurrentOrganization, + "/{org}/{project}/_apis/git/repositories/{repository}/refs", + CurrentRepositoryPath, + Json, + &[ + "filter", + "filterContains", + "includeLinks", + "includeStatuses", + "includeMyBranches", + "latestStatusesOnly", + "peelTags", + "$top", + "continuationToken", + ] + ), + Operation { + denied_query: &["download", "$format", "zipForUnix"], + ..get!( + "repos.items-list", + Repos, + CurrentOrganization, + "/{org}/{project}/_apis/git/repositories/{repository}/items", + CurrentRepositoryPath, + Json, + &[ + "path", + "scopePath", + "recursionLevel", + "includeContentMetadata", + "latestProcessedChange", + "includeLinks", + "versionDescriptor.version", + "versionDescriptor.versionType", + "versionDescriptor.versionOptions", + ] + ) + }, + get!( + "repos.commits-list", + Repos, + CurrentOrganization, + "/{org}/{project}/_apis/git/repositories/{repository}/commits", + CurrentRepositoryPath, + Json, + &["$top", "$skip", "searchCriteria"] + ), + get!( + "repos.commit-get", + Repos, + CurrentOrganization, + "/{org}/{project}/_apis/git/repositories/{repository}/commits/{commitId}", + CurrentRepositoryPath, + Json, + &["changeCount"] + ), + get!( + "repos.commit-changes", + Repos, + CurrentOrganization, + "/{org}/{project}/_apis/git/repositories/{repository}/commits/{commitId}/changes", + CurrentRepositoryPath, + Json, + &["top", "skip"] + ), + get!( + "repos.pull-requests-list", + Repos, + CurrentOrganization, + "/{org}/{project}/_apis/git/repositories/{repository}/pullrequests", + CurrentRepositoryPath, + Json, + &["searchCriteria", "$top", "$skip", "maxCommentLength"] + ), + get!( + "repos.pull-request-get", + Repos, + CurrentOrganization, + "/{org}/{project}/_apis/git/repositories/{repository}/pullrequests/{pullRequestId}", + CurrentRepositoryPath, + Json, + &[ + "maxCommentLength", + "$top", + "$skip", + "includeCommits", + "includeWorkItemRefs", + ] + ), + get!( + "repos.pull-request-get-by-id", + Repos, + CurrentOrganization, + "/{org}/_apis/git/pullrequests/{pullRequestId}", + ResponseCurrentRepository, + ValidateProjectAndRepository, + &["maxCommentLength", "includeCommits", "includeWorkItemRefs"] + ), + get!( + "repos.pull-request-threads", + Repos, + CurrentOrganization, + "/{org}/{project}/_apis/git/repositories/{repository}/pullrequests/{pullRequestId}/threads", + CurrentRepositoryPath, + Json, + &["$top", "$skip", "iteration", "baseIteration"] + ), + get!( + "repos.pull-request-iterations", + Repos, + CurrentOrganization, + "/{org}/{project}/_apis/git/repositories/{repository}/pullrequests/{pullRequestId}/iterations", + CurrentRepositoryPath, + Json, + &["includeCommits"] + ), + get!( + "repos.pull-request-reviewers", + Repos, + CurrentOrganization, + "/{org}/{project}/_apis/git/repositories/{repository}/pullrequests/{pullRequestId}/reviewers", + CurrentRepositoryPath, + Json, + NO_QUERY + ), + get!( + "repos.pull-request-work-items", + Repos, + CurrentOrganization, + "/{org}/{project}/_apis/git/repositories/{repository}/pullrequests/{pullRequestId}/workitems", + CurrentRepositoryPath, + Json, + NO_QUERY + ), + get!( + "pipelines.definitions-list", + Pipelines, + CurrentOrganization, + "/{org}/{project}/_apis/build/definitions", + CurrentProjectPath, + Json, + &[ + "name", + "repositoryId", + "repositoryType", + "$top", + "continuationToken", + "path" + ] + ), + get!( + "pipelines.definition-get", + Pipelines, + CurrentOrganization, + "/{org}/{project}/_apis/build/definitions/{definitionId}", + CurrentProjectPath, + Json, + &["revision", "propertyFilters", "includeLatestBuilds"] + ), + get!( + "pipelines.builds-list", + Pipelines, + CurrentOrganization, + "/{org}/{project}/_apis/build/builds", + CurrentProjectPath, + Json, + &[ + "definitions", + "buildNumber", + "minTime", + "maxTime", + "reasonFilter", + "statusFilter", + "resultFilter", + "$top", + "continuationToken", + "queryOrder", + "branchName", + "buildIds", + "repositoryId", + "repositoryType", + ] + ), + get!( + "pipelines.build-get", + Pipelines, + CurrentOrganization, + "/{org}/{project}/_apis/build/builds/{buildId}", + CurrentProjectPath, + Json, + &["propertyFilters"] + ), + get!( + "pipelines.timeline-get", + Pipelines, + CurrentOrganization, + "/{org}/{project}/_apis/build/builds/{buildId}/timeline", + CurrentProjectPath, + Json, + &["changeId", "planId"] + ), + get!( + "pipelines.pipeline-list", + Pipelines, + CurrentOrganization, + "/{org}/{project}/_apis/pipelines", + CurrentProjectPath, + Json, + &["orderBy", "$top", "continuationToken"] + ), + get!( + "pipelines.pipeline-get", + Pipelines, + CurrentOrganization, + "/{org}/{project}/_apis/pipelines/{pipelineId}", + CurrentProjectPath, + Json, + &["pipelineVersion"] + ), + get!( + "pipelines.runs-list", + Pipelines, + CurrentOrganization, + "/{org}/{project}/_apis/pipelines/{pipelineId}/runs", + CurrentProjectPath, + Json, + NO_QUERY + ), + get!( + "pipelines.run-get", + Pipelines, + CurrentOrganization, + "/{org}/{project}/_apis/pipelines/{pipelineId}/runs/{runId}", + CurrentProjectPath, + Json, + NO_QUERY + ), + get!( + "boards.work-item-get", + Boards, + CurrentOrganization, + "/{org}/{project}/_apis/wit/workitems/{id}", + CurrentProjectPath, + Json, + &["fields", "asOf", "$expand"] + ), + get!( + "boards.work-item-get-by-id", + Boards, + CurrentOrganization, + "/{org}/_apis/wit/workitems/{id}", + ResponseCurrentProject, + ValidateProject, + &["fields", "asOf", "$expand"] + ), + get!( + "boards.work-item-comments", + Boards, + CurrentOrganization, + "/{org}/{project}/_apis/wit/workitems/{id}/comments", + CurrentProjectPath, + Json, + &[ + "$top", + "continuationToken", + "includeDeleted", + "expand", + "order" + ] + ), + get!( + "boards.work-item-updates", + Boards, + CurrentOrganization, + "/{org}/{project}/_apis/wit/workitems/{id}/updates", + CurrentProjectPath, + Json, + &["$top", "$skip"] + ), + get!( + "boards.work-item-revisions", + Boards, + CurrentOrganization, + "/{org}/{project}/_apis/wit/workitems/{id}/revisions", + CurrentProjectPath, + Json, + &["$top", "$skip", "$expand"] + ), + ] +} + +pub const DENIED_ROUTE_FAMILIES: &[&str] = &[ + "/_apis/accesscontrollists", + "/_apis/accesscontrolentries", + "/_apis/securitynamespaces", + "/_apis/permissions", + "/_apis/tokens", + "/_apis/tokenadmin", + "/_apis/delegatedauth", + "/_apis/oauth2", + "/_apis/serviceendpoint", + "/_apis/distributedtask/variablegroups", + "/_apis/distributedtask/securefiles", + "/_apis/build/builds/{buildId}/oauthtoken", + "/_apis/build/builds/{buildId}/artifacts", + "/_apis/build/builds/{buildId}/logs", + "/_apis/build/builds/{buildId}/attachments", + "/_apis/wit/wiql", + "/_apis/wit/workitemsbatch", + "/_apis/wit/workitems?ids=", + "/_apis/git/repositories/{repository}/itemsbatch", + "/_apis/git/repositories/{repository}/commitsbatch", + "/_apis/git/repositories/{repository}/blobs", + "/_apis/git/repositories/{repository}/trees", + "/_git/", + "/_odata/", + "/_apis/search/", + "/_apis/customerintelligence/events", +]; + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn catalog_is_versioned_and_runtime_stays_disabled() { + let catalog = catalog(); + assert_eq!(catalog.schema_version, CATALOG_SCHEMA_VERSION); + assert!(!catalog.runtime_available); + assert!(!catalog.operations.is_empty()); + } + + #[test] + fn operation_ids_are_unique_and_routes_are_normalized() { + let mut ids = HashSet::new(); + for operation in operations() { + assert!(ids.insert(operation.id), "duplicate id {}", operation.id); + assert!(operation.route.starts_with('/')); + let mut in_parameter = false; + for character in operation.route.chars() { + match character { + '{' => in_parameter = true, + '}' => in_parameter = false, + _ if !in_parameter => { + assert!( + !character.is_ascii_uppercase(), + "fixed route segments must be lowercase: {}", + operation.route + ); + } + _ => {} + } + } + assert!(matches!( + operation.method, + HttpMethod::Get | HttpMethod::Options + )); + } + } + + #[test] + fn discovery_and_response_scoped_operations_are_explicit() { + let entries = operations(); + assert!(entries.iter().any(|entry| { + entry.id == "discovery.host-options" + && entry.method == HttpMethod::Options + && entry.api_version == API_VERSION_ABSENT + })); + assert!(entries.iter().any(|entry| { + entry.id == "repos.pull-request-get-by-id" + && entry.response == ResponsePolicy::ValidateProjectAndRepository + })); + assert!(entries.iter().any(|entry| { + entry.id == "boards.work-item-get-by-id" + && entry.response == ResponsePolicy::ValidateProject + })); + } + + #[test] + fn protected_hosts_exclude_package_and_token_services() { + let hosts = catalog().protected_hosts; + for denied in [ + "pkgs.dev.azure.com", + "artifacts.dev.azure.com", + "vstoken.dev.azure.com", + "vssps.dev.azure.com", + ] { + assert!(!hosts.contains(&denied)); + } + } + + #[test] + fn known_sensitive_families_are_default_denied() { + for required in [ + "/_apis/serviceendpoint", + "/_apis/distributedtask/variablegroups", + "/_apis/distributedtask/securefiles", + "/_apis/build/builds/{buildId}/oauthtoken", + "/_git/", + ] { + assert!(DENIED_ROUTE_FAMILIES.contains(&required)); + } + } + + /// The human-readable `API_VERSION_RANGE` marker and the machine-readable + /// bounds are both exported to the sidecar. If they ever disagree, the + /// bundle would enforce a different window than the catalog advertises. + #[test] + fn api_version_marker_matches_the_exported_bounds() { + let (min_major, min_minor) = API_VERSION_MIN; + let (max_major, max_minor) = API_VERSION_MAX; + assert!( + API_VERSION_RANGE.starts_with(&format!("{min_major}.{min_minor}")), + "range marker must start at API_VERSION_MIN: {API_VERSION_RANGE}" + ); + assert!( + API_VERSION_RANGE.contains(&format!("{max_major}.{max_minor}")), + "range marker must name API_VERSION_MAX: {API_VERSION_RANGE}" + ); + assert!( + (min_major, min_minor) < (max_major, max_minor), + "API version bounds must be ordered" + ); + } +} diff --git a/src/ado_proxy/mod.rs b/src/ado_proxy/mod.rs new file mode 100644 index 000000000..28a3d121e --- /dev/null +++ b/src/ado_proxy/mod.rs @@ -0,0 +1,37 @@ +//! Credential-isolated Azure DevOps proxy policy. +//! +//! This module owns the **authoritative** definition of which Azure DevOps +//! operations a Stage 1 agent may perform. It is deliberately data-only: the +//! proxy *runtime* that enforces this policy ships as the +//! `ado-proxy` TypeScript bundle in `scripts/ado-script/`, alongside +//! the other `ado-script` bundles, and is downloaded into the pipeline as part +//! of `ado-script.zip`. +//! +//! # Why the runtime is not here +//! +//! A Rust runtime would need a TLS stack plus certificate minting +//! (`rustls` + `rcgen`), which pulls in `ring` — C and assembly — making a +//! native toolchain a hard build requirement for the whole compiler. ado-aw is +//! otherwise pure-Rust and must stay buildable without one. Node's built-in +//! `tls`/`http`/`net` modules cover the same ground with no new dependency, and +//! match how AWF implements its own credential-isolating sidecars. +//! +//! # Avoiding divergence between compiler and sidecar +//! +//! The compiler *emits* the policy document and the sidecar *consumes* it, so +//! the two must not drift. Rather than maintaining a second copy by hand, every +//! other artefact is generated from the types in [`catalog`]: +//! +//! 1. the JSON Schema, exported by `ado-aw export-ado-proxy-catalog-schema` +//! and turned into TypeScript types by the `ado-script` `codegen` script, so +//! the bundle cannot compile against a stale shape; +//! 2. a committed data snapshot, exported by `ado-aw export-ado-proxy-catalog`, +//! guarded by a drift test that re-runs the exporter and fails on any diff; +//! 3. [`catalog::CATALOG_SCHEMA_VERSION`], embedded in the emitted policy +//! document and re-checked by the sidecar at startup, so a stale mounted +//! policy file fails closed instead of under-enforcing. +//! +//! This mirrors the existing `export-gate-schema` / `export-fact-catalog` +//! pattern used by the gate evaluator. + +pub mod catalog; diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index fe1ea408d..4e80f9701 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -134,6 +134,7 @@ pub(crate) fn build_pipeline_context( // ─── Validations (reuse all shared validators) ──────────────── common::validate_front_matter_identity(front_matter)?; + common::validate_permissions_read_policy(front_matter)?; common::validate_variable_groups(front_matter)?; common::validate_checkout_self_collision( &front_matter.repositories, @@ -324,7 +325,8 @@ pub(crate) fn build_pipeline_context( front_matter .permissions .as_ref() - .and_then(|p| p.read.as_deref()), + .and_then(|p| p.read.as_ref()) + .map(crate::compile::types::ReadPermissionConfig::service_connection), "SC_READ_TOKEN", ); let acquire_write_token = common::generate_acquire_ado_token( diff --git a/src/compile/common.rs b/src/compile/common.rs index 65846e14d..d743cef1d 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -453,6 +453,30 @@ pub fn validate_front_matter_identity(front_matter: &FrontMatter) -> Result<()> Ok(()) } +/// Reject explicit Stage 1 read-policy options until the credential-isolated +/// proxy enforces them. +/// +/// Deserializing the object form now lets the typed schema and validation +/// evolve independently, but compiling it as the legacy scalar behavior would +/// silently ignore scope/capability restrictions. Fail closed until the proxy +/// wiring consumes the policy. +pub fn validate_permissions_read_policy(front_matter: &FrontMatter) -> Result<()> { + let explicit_options = front_matter + .permissions + .as_ref() + .and_then(|permissions| permissions.read.as_ref()) + .and_then(crate::compile::types::ReadPermissionConfig::options); + + if explicit_options.is_some() { + anyhow::bail!( + "permissions.read object form requires the credential-isolated Azure DevOps proxy, \ + which is not enabled in this compiler yet. Use the scalar service-connection \ + shorthand for the current trusted MCP behavior." + ); + } + Ok(()) +} + /// Validate the `variable-groups:` front-matter block (issue #1385). /// /// Enforces two rules before the pipeline is built: @@ -5359,6 +5383,24 @@ safe-outputs: assert!(result.unwrap_err().to_string().contains("ADO expression")); } + #[test] + fn test_validate_permissions_read_policy_allows_scalar_and_rejects_object() { + let (scalar, _) = parse_markdown( + "---\nname: test\ndescription: test\npermissions:\n read: my-read-sc\n---\n", + ) + .unwrap(); + validate_permissions_read_policy(&scalar).unwrap(); + + let (object, _) = parse_markdown( + "---\nname: test\ndescription: test\npermissions:\n read:\n service-connection: my-read-sc\n capabilities: [repos]\n---\n", + ) + .unwrap(); + let error = validate_permissions_read_policy(&object) + .unwrap_err() + .to_string(); + assert!(error.contains("credential-isolated Azure DevOps proxy")); + } + #[test] fn test_validate_front_matter_identity_rejects_macro_in_description() { let mut fm = minimal_front_matter(); diff --git a/src/compile/types.rs b/src/compile/types.rs index 7796735f1..0175c3d3b 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -1312,11 +1312,11 @@ pub struct FrontMatter { pub teardown: Vec, /// Permissions configuration for ADO access tokens. /// - /// ADO supports two access levels: blanket read and blanket write. - /// Tokens are minted from ARM service connections — System.AccessToken is never used. + /// The field names describe the intended pipeline roles. Effective Azure + /// DevOps permissions come from the underlying identities' ADO grants. /// - /// - `read`: MI for Stage 1 (agent) — read-only ADO access - /// - `write`: MI for Stage 3 (executor) — write access for safe-outputs, never given to agent + /// - `read`: ARM service connection used by the trusted Stage 1 ADO MCP + /// - `write`: ARM service connection used by the Stage 3 safe-output executor #[serde(default)] pub permissions: Option, /// When `true`, the compiler inlines all `{{#runtime-import …}}` markers @@ -1823,9 +1823,9 @@ pub struct NetworkConfig { /// Permissions configuration for ADO access tokens. /// -/// ADO does not support fine-grained permissions. There are two access levels: -/// blanket read and blanket write, each backed by an ARM service connection -/// that mints an ADO-scoped token. +/// `read` and `write` identify intended pipeline roles, not token-enforced +/// access levels. Azure DevOps authorizes each underlying service-connection +/// identity independently of its Azure RBAC scope. /// /// Examples: /// ```yaml @@ -1834,20 +1834,20 @@ pub struct NetworkConfig { /// read: my-read-arm-connection /// write: my-write-arm-connection /// -/// # Read-only (agent can query ADO APIs, no write safe-outputs) +/// # Stage 1 ADO MCP authentication /// permissions: /// read: my-read-arm-connection /// -/// # Write-only (safe-outputs can write, agent gets no ADO token) +/// # Stage 3 override only /// permissions: /// write: my-write-arm-connection /// ``` #[derive(Debug, Deserialize, Clone, Default, SanitizeConfig)] pub struct PermissionsConfig { - /// ARM service connection for read-only ADO access. - /// Token is minted and given to the agent in Stage 1 (inside AWF sandbox). + /// ARM service connection for the trusted Stage 1 Azure DevOps MCP. + /// The raw token is not injected into the Agent process. #[serde(default)] - pub read: Option, + pub read: Option, /// ARM service connection for write ADO access. /// Token is minted and used only by the executor in Stage 3 (Execution). /// This token is never exposed to the agent. @@ -1855,6 +1855,90 @@ pub struct PermissionsConfig { pub write: Option, } +/// Stage 1 Azure DevOps credential and policy configuration. +/// +/// The scalar form remains shorthand for a service connection with the +/// compiler-owned current-organization/project/repository policy. The object +/// form prepares explicit policy configuration for the credential-isolated +/// proxy and is rejected by compilation until that runtime is wired. +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(untagged)] +pub enum ReadPermissionConfig { + /// Backward-compatible service-connection shorthand. + ServiceConnection(crate::secure::ServiceConnection), + /// Explicit proxy policy configuration. + WithOptions(ReadPermissionOptions), +} + +impl ReadPermissionConfig { + /// The ARM service connection used to mint the Stage 1 ADO credential. + pub fn service_connection(&self) -> &str { + match self { + Self::ServiceConnection(value) => value.as_str(), + Self::WithOptions(options) => options.service_connection.as_str(), + } + } + + /// Explicit policy options, when object form was used. + pub fn options(&self) -> Option<&ReadPermissionOptions> { + match self { + Self::ServiceConnection(_) => None, + Self::WithOptions(options) => Some(options), + } + } +} + +impl SanitizeConfigTrait for ReadPermissionConfig { + fn sanitize_config_fields(&mut self) { + // Every string field is a validated newtype checked at deserialization. + } +} + +/// Explicit Stage 1 Azure DevOps read-policy options. +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReadPermissionOptions { + /// ARM service connection used by the trusted credential path. + #[serde(rename = "service-connection")] + pub service_connection: crate::secure::ServiceConnection, + /// Optional capability groups. Empty selects the compiler-owned safe + /// default catalog. + #[serde(default)] + pub capabilities: Vec, + /// Additional scopes beyond the implicit current org/project/repository. + #[serde(default)] + pub allow: Vec, +} + +/// Coarse catalog groups authors may enable for Stage 1 ADO reads. +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum AdoReadCapability { + Core, + #[serde(rename = "repos")] + Repositories, + Pipelines, + Boards, +} + +/// Explicit Azure DevOps organization scope. +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct AdoReadOrganizationScope { + pub organization: crate::secure::AdoOrganization, + #[serde(default)] + pub projects: Vec, +} + +/// Explicit project and optional repository scope within an organization. +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct AdoReadProjectScope { + pub project: crate::secure::AdoProject, + #[serde(default)] + pub repositories: Vec, +} + /// Debug-only configuration block. /// /// Lives under the `ado-aw-debug:` top-level front-matter key. Holds knobs @@ -3857,7 +3941,12 @@ github-app-token: fn test_permissions_both_fields() { let yaml = "read: my-read-sc\nwrite: my-write-sc"; let pc: PermissionsConfig = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(pc.read.as_deref(), Some("my-read-sc")); + assert_eq!( + pc.read + .as_ref() + .map(ReadPermissionConfig::service_connection), + Some("my-read-sc") + ); assert_eq!(pc.write.as_deref(), Some("my-write-sc")); } @@ -3865,10 +3954,65 @@ github-app-token: fn test_permissions_read_only() { let yaml = "read: my-read-sc"; let pc: PermissionsConfig = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(pc.read.as_deref(), Some("my-read-sc")); + assert_eq!( + pc.read + .as_ref() + .map(ReadPermissionConfig::service_connection), + Some("my-read-sc") + ); assert!(pc.write.is_none()); } + #[test] + fn test_permissions_read_object_form() { + let yaml = r#" +read: + service-connection: my-read-sc + capabilities: [core, repos, pipelines, boards] + allow: + - organization: other-org + projects: + - project: Other Project + repositories: [Repo One, 01234567-89ab-cdef-0123-456789abcdef] +"#; + let pc: PermissionsConfig = serde_yaml::from_str(yaml).unwrap(); + let read = pc.read.as_ref().unwrap(); + assert_eq!(read.service_connection(), "my-read-sc"); + let options = read.options().unwrap(); + assert_eq!( + options.capabilities, + vec![ + AdoReadCapability::Core, + AdoReadCapability::Repositories, + AdoReadCapability::Pipelines, + AdoReadCapability::Boards, + ] + ); + assert_eq!(options.allow[0].organization.as_str(), "other-org"); + assert_eq!( + options.allow[0].projects[0].project.as_str(), + "Other Project" + ); + assert_eq!( + options.allow[0].projects[0].repositories[0].as_str(), + "Repo One" + ); + } + + #[test] + fn test_permissions_read_object_form_rejects_invalid_scope() { + for yaml in [ + "read:\n service-connection: sc\n allow:\n - organization: 'bad/org'", + "read:\n service-connection: sc\n allow:\n - organization: org\n projects:\n - project: Project\n repositories: ['../repo']", + "read:\n service-connection: sc\n unknown: value", + ] { + assert!( + serde_yaml::from_str::(yaml).is_err(), + "invalid read policy must fail deserialization:\n{yaml}" + ); + } + } + #[test] fn test_permissions_write_only() { let yaml = "write: my-write-sc"; @@ -3902,7 +4046,13 @@ Body "#; let (fm, _) = super::super::common::parse_markdown(content).unwrap(); let perms = fm.permissions.unwrap(); - assert_eq!(perms.read.as_deref(), Some("my-read-sc")); + assert_eq!( + perms + .read + .as_ref() + .map(ReadPermissionConfig::service_connection), + Some("my-read-sc") + ); assert_eq!(perms.write.as_deref(), Some("my-write-sc")); } diff --git a/src/inspect/catalog.rs b/src/inspect/catalog.rs index 5a34c866e..8e127b811 100644 --- a/src/inspect/catalog.rs +++ b/src/inspect/catalog.rs @@ -58,6 +58,8 @@ pub struct Catalog { pub models: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub versions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ado_proxy: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -69,7 +71,7 @@ impl fmt::Display for UnknownCatalogKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "unknown --kind '{}' (expected one of: safe-outputs, runtimes, tools, engines, models, versions)", + "unknown --kind '{}' (expected one of: safe-outputs, runtimes, tools, engines, models, versions, ado-proxy)", self.kind ) } @@ -85,6 +87,7 @@ pub enum CatalogKind { Engines, Models, Versions, + AdoProxy, } impl CatalogKind { @@ -96,6 +99,7 @@ impl CatalogKind { "engines" => Ok(Self::Engines), "models" => Ok(Self::Models), "versions" => Ok(Self::Versions), + "ado-proxy" => Ok(Self::AdoProxy), other => Err(UnknownCatalogKind { kind: other.to_string(), }), @@ -111,6 +115,7 @@ pub fn catalog() -> Catalog { engines: engines(), models: models(), versions: Some(versions()), + ado_proxy: Some(crate::ado_proxy::catalog::catalog()), } } @@ -141,6 +146,10 @@ pub fn catalog_kind(kind: &str) -> Result { versions: Some(versions()), ..Catalog::default() }, + CatalogKind::AdoProxy => Catalog { + ado_proxy: Some(crate::ado_proxy::catalog::catalog()), + ..Catalog::default() + }, }) } @@ -193,6 +202,28 @@ pub fn render_text(catalog: &Catalog) -> String { out.push_str(&format!(" copilot-cli {}\n", versions.copilot_cli)); out.push_str(&format!(" awf {}\n", versions.awf)); out.push_str(&format!(" mcpg {}\n", versions.mcpg)); + out.push('\n'); + } + if let Some(proxy) = &catalog.ado_proxy { + out.push_str("Azure DevOps proxy\n"); + out.push_str(&format!(" schema: {}\n", proxy.schema_version)); + out.push_str(&format!( + " runtime: {}\n", + if proxy.runtime_available { + "available" + } else { + "policy-schema-only" + } + )); + for operation in &proxy.operations { + out.push_str(&format!( + " {} [{} {}]\n", + operation.id, + operation.method.as_str(), + operation.route + )); + } + out.push('\n'); } out.trim_end().to_string() } @@ -386,4 +417,17 @@ mod tests { assert_eq!(value["versions"]["awf"], AWF_VERSION); assert_eq!(value["versions"]["mcpg"], MCPG_VERSION); } + + #[test] + fn ado_proxy_catalog_reports_policy_only_runtime() { + let catalog = catalog_kind("ado-proxy").unwrap(); + let proxy = catalog.ado_proxy.unwrap(); + assert_eq!( + proxy.schema_version, + crate::ado_proxy::catalog::CATALOG_SCHEMA_VERSION + ); + assert!(!proxy.runtime_available); + assert!(!proxy.operations.is_empty()); + assert!(catalog.safe_outputs.is_empty()); + } } diff --git a/src/main.rs b/src/main.rs index 13d16fd8a..e92555d8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ pub mod ado; mod agent_stats; mod allowed_hosts; mod audit; +mod ado_proxy; mod compile; mod configure; mod detect; @@ -567,6 +568,22 @@ enum Commands { #[arg(short, long)] output: Option, }, + /// Export the `ado-proxy` catalog JSON Schema (build-time tool for the + /// scripts/ado-script TypeScript workspace). + #[command(hide = true)] + ExportAdoProxyCatalogSchema { + /// Output path; if omitted, prints to stdout. + #[arg(short, long)] + output: Option, + }, + /// Export the `ado-proxy` catalog data JSON — build-time drift guard for + /// the `ado-proxy` bundle's committed catalog snapshot. + #[command(hide = true)] + ExportAdoProxyCatalog { + /// Output path; if omitted, prints to stdout. + #[arg(short, long)] + output: Option, + }, /// Inspect an agent source file's typed IR: jobs, stages, steps, outputs, derived `dependsOn`. Inspect { /// Path to the agent markdown source. @@ -968,6 +985,31 @@ fn print_execution_summary(results: &[crate::safe_outputs::ExecutionResult]) { ); } +/// Write a build-time generated artifact (JSON Schema or catalog data) to +/// `output`, or to stdout when no path is given. +/// +/// Shared by every `export-*` command so the parent-directory creation and +/// error context stay identical across generators. `label` names the artifact +/// in error messages (e.g. `"gate schema"`). +fn write_generated_artifact(output: Option<&Path>, contents: &str, label: &str) -> Result<()> { + match output { + Some(path) => { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent).with_context(|| { + format!("creating parent dir for {label}: {}", parent.display()) + })?; + } + std::fs::write(path, contents) + .with_context(|| format!("writing {label} to {}", path.display()))?; + } + None => print!("{}", contents), + } + Ok(()) +} + #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); @@ -992,6 +1034,8 @@ async fn main() -> Result<()> { Some(Commands::Trace { .. }) => "trace", Some(Commands::ExportGateSchema { .. }) => "export-gate-schema", Some(Commands::ExportFactCatalog { .. }) => "export-fact-catalog", + Some(Commands::ExportAdoProxyCatalogSchema { .. }) => "export-ado-proxy-catalog-schema", + Some(Commands::ExportAdoProxyCatalog { .. }) => "export-ado-proxy-catalog", Some(Commands::Inspect { .. }) => "inspect", Some(Commands::Graph { .. }) => "graph", Some(Commands::Whatif { .. }) => "whatif", @@ -1379,40 +1423,32 @@ async fn main() -> Result<()> { .await?; } Commands::ExportGateSchema { output } => { - let schema = compile::filter_ir::generate_gate_spec_schema(); - match output { - Some(path) => { - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - std::fs::create_dir_all(parent).with_context(|| { - format!("creating parent dir for gate schema: {}", parent.display()) - })?; - } - std::fs::write(&path, &schema) - .with_context(|| format!("writing gate schema to {}", path.display()))?; - } - None => print!("{}", schema), - } + write_generated_artifact( + output.as_deref(), + &compile::filter_ir::generate_gate_spec_schema(), + "gate schema", + )?; } Commands::ExportFactCatalog { output } => { - let catalog = compile::filter_ir::generate_fact_catalog(); - match output { - Some(path) => { - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - std::fs::create_dir_all(parent).with_context(|| { - format!("creating parent dir for fact catalog: {}", parent.display()) - })?; - } - std::fs::write(&path, &catalog) - .with_context(|| format!("writing fact catalog to {}", path.display()))?; - } - None => print!("{}", catalog), - } + write_generated_artifact( + output.as_deref(), + &compile::filter_ir::generate_fact_catalog(), + "fact catalog", + )?; + } + Commands::ExportAdoProxyCatalogSchema { output } => { + write_generated_artifact( + output.as_deref(), + &ado_proxy::catalog::generate_catalog_schema(), + "ado-proxy catalog schema", + )?; + } + Commands::ExportAdoProxyCatalog { output } => { + write_generated_artifact( + output.as_deref(), + &ado_proxy::catalog::generate_catalog_json(), + "ado-proxy catalog", + )?; } Commands::Inspect { source, json } => { inspect::dispatch_inspect(inspect::InspectOptions { diff --git a/src/mcp_author/mod.rs b/src/mcp_author/mod.rs index 8ffd8582b..03e68585d 100644 --- a/src/mcp_author/mod.rs +++ b/src/mcp_author/mod.rs @@ -101,7 +101,8 @@ struct WhatIfParams { #[derive(Debug, Deserialize, JsonSchema)] struct CatalogParams { - /// Optional category: safe-outputs, runtimes, tools, engines, models, or versions. + /// Optional category: safe-outputs, runtimes, tools, engines, models, + /// versions, or ado-proxy. kind: Option, } @@ -301,7 +302,7 @@ impl AuthorMcp { #[tool( name = "catalog", - description = "List supported safe-outputs, runtimes, tools, engines, models, and pinned versions." + description = "List supported safe-outputs, runtimes, tools, engines, models, pinned versions, and the Azure DevOps proxy policy catalog." )] async fn catalog(&self, params: Parameters) -> Result { let catalog = inspect::build_catalog(params.0.kind.as_deref()).map_err(to_mcp_error)?; diff --git a/src/secure.rs b/src/secure.rs index 0490962c0..f01738a8a 100644 --- a/src/secure.rs +++ b/src/secure.rs @@ -32,7 +32,9 @@ //! - [`Identifier`] — an engine agent/model identifier. //! - [`HostName`] — a DNS-style hostname. //! - [`RegistryRef`] — a container-registry host or base path. +//! - [`AdoOrganization`] — an Azure DevOps Services organization name. //! - [`AdoProject`] — an Azure DevOps project name or GUID. +//! - [`AdoRepository`] — an Azure DevOps repository name or GUID. //! - [`Version`] — a version string (`1.2.3`, `latest`). //! //! New safe-output tools that accept paths or identifiers should type those @@ -325,6 +327,27 @@ validated_string! { } } +validated_string! { + /// An Azure DevOps Services organization name. + AdoOrganization, "organization", |value: &str, label: &str| { + let valid = !value.is_empty() + && value.len() <= 64 + && !value.starts_with('-') + && !value.ends_with('-') + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-'); + if valid { + Ok(()) + } else { + anyhow::bail!( + "{label} '{value}' must be 1-64 ASCII alphanumeric or '-' \ + characters and must not start or end with '-'" + ) + } + } +} + validated_string! { /// An Azure DevOps project name or GUID. AdoProject, "project", |value: &str, label: &str| { @@ -366,6 +389,64 @@ validated_string! { } } +validated_string! { + /// An Azure DevOps repository name or GUID. + AdoRepository, "repository", |value: &str, label: &str| { + let bytes = value.as_bytes(); + let is_guid = bytes.len() == 36 + && bytes.iter().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + *byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }); + let invalid_name_char = |c: char| { + c.is_control() + || matches!( + c, + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' + | ';' | '#' | '$' | '{' | '}' | '[' | ']' + ) + }; + let char_count = value.chars().count(); + let is_name = char_count > 0 + && char_count <= 64 + && value.trim() == value + && !value.starts_with('.') + && !value.ends_with('.') + && !value.chars().any(invalid_name_char); + + if is_guid || is_name { + Ok(()) + } else { + anyhow::bail!( + "{label} '{value}' must be an Azure DevOps repository name \ + (1-64 characters, no reserved punctuation or \ + leading/trailing '.') or a canonical GUID" + ) + } + } +} + +validated_string! { + /// A canonical `8-4-4-4-12` hex GUID. + /// + /// Used by the Azure DevOps policy proxy for project / repository / + /// resource-area identifiers, where a scope comparison must be an exact + /// match rather than a name lookup. See + /// [`crate::validate::is_valid_guid`]. + Guid, "guid", |value: &str, label: &str| { + if validate::is_valid_guid(value) { + Ok(()) + } else { + anyhow::bail!( + "{label} '{value}' must be a canonical 8-4-4-4-12 hex GUID" + ) + } + } +} + validated_string! { /// An Azure resource (audience) URL passed to /// `az account get-access-token --resource`. Validated to be a shell-safe, @@ -468,6 +549,35 @@ mod tests { assert!(AdoProject::parse("x".repeat(65)).is_err()); } + #[test] + fn ado_organization_rules() { + assert!(AdoOrganization::parse("contoso-dev").is_ok()); + assert!(AdoOrganization::parse("-contoso").is_err()); + assert!(AdoOrganization::parse("contoso/other").is_err()); + assert!(AdoOrganization::parse("x".repeat(65)).is_err()); + } + + #[test] + fn ado_repository_name_or_guid_rules() { + assert!(AdoRepository::parse("Repo One").is_ok()); + assert!(AdoRepository::parse("12345678-1234-1234-1234-1234567890ab").is_ok()); + assert!(AdoRepository::parse("../repo").is_err()); + assert!(AdoRepository::parse("bad/repo").is_err()); + assert!(AdoRepository::parse("bad$(macro)").is_err()); + } + + #[test] + fn guid_accepts_only_the_canonical_form() { + assert!(Guid::parse("12345678-1234-1234-1234-1234567890ab").is_ok()); + assert!(Guid::parse("12345678-1234-1234-1234-1234567890AB").is_ok()); + assert!(Guid::parse("{12345678-1234-1234-1234-1234567890ab}").is_err()); + assert!(Guid::parse("urn:uuid:12345678-1234-1234-1234-1234567890ab").is_err()); + assert!(Guid::parse("12345678123412341234567890ab").is_err()); + assert!(Guid::parse("12345678-1234-1234-1234-1234567890ag").is_err()); + assert!(Guid::parse(" 12345678-1234-1234-1234-1234567890ab").is_err()); + assert!(Guid::parse("").is_err()); + } + #[test] fn deserialize_validates() { // Valid value round-trips. diff --git a/src/validate.rs b/src/validate.rs index 6d293f458..772485049 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -725,6 +725,27 @@ pub fn ensure_path_within_base(candidate: &Path, base: &Path, label: &str) -> Re Ok(canonical) } +// ── Identifier validators ──────────────────────────────────────────────────── + +/// Return `true` if `s` is a canonical `8-4-4-4-12` hex GUID (no braces, no +/// URN prefix, no surrounding whitespace). +/// +/// Azure DevOps returns GUIDs in this exact shape for project, repository, and +/// resource-area identifiers. Accepting only the canonical form keeps scope +/// comparisons a byte-wise (ASCII case-insensitive) match instead of a +/// normalization problem. +pub fn is_valid_guid(s: &str) -> bool { + let bytes = s.as_bytes(); + bytes.len() == 36 + && bytes.iter().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + *byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + // ── Git reference / commit validators ──────────────────────────────────────── /// Return `true` if `s` is a full 40-character lowercase-or-uppercase hex SHA. From 87516f9016004135cbfca9a68a27b6e32e1de051 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 31 Jul 2026 21:54:02 +0100 Subject: [PATCH 03/42] test(ado-script): budget for Vite transform time in the test timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compiler-smoke-e2e/index.test.ts` failed intermittently in full-suite runs while passing in isolation, and the reported error pointed at the wrong test: (happy path) Test timed out in 5000ms (unexpected path) expected +0 to be 1 Both symptoms have one cause. These tests call `await import("../index.js")` inside the test body, so Vite's on-demand transform of that module's whole dependency graph is charged to the test's 5s budget. That is infrastructure work, not test work, and it is wildly variable: measured between ~8s and ~158s of transform across the suite on the same machine depending on cache state and load. The happy-path test needs ~1.6s when warm, so a cold or contended run tips it over. The second failure is a cascade. Vitest fails a timed-out test but does not cancel the promise, so the abandoned `main()` kept running and consumed the `mockResolvedValueOnce` that the *next* test had queued for `worktreeChangedFiles`. That test then took the clean-path branch and returned 0 instead of 1 — an assertion failure with no visible connection to the timeout that caused it. Raise `testTimeout`/`hookTimeout` to 30s, which is what the Vitest error message itself recommends. A genuine hang still fails, just later. Reproduced by clearing `node_modules/.vite` and running the full suite under CPU contention; verified with five consecutive clean runs under the same conditions, including one with 147s of transform time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- scripts/ado-script/vitest.config.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/ado-script/vitest.config.ts b/scripts/ado-script/vitest.config.ts index 331ec7eed..ffb7e007d 100644 --- a/scripts/ado-script/vitest.config.ts +++ b/scripts/ado-script/vitest.config.ts @@ -6,6 +6,25 @@ export default defineConfig({ // under test/ depends on gate.js/import.js existing, so it runs via a // separate config — see vitest.config.smoke.ts and `npm run test:smoke`. include: ["src/**/*.test.ts"], + + // Vitest's 5s/10s defaults are too tight for this workspace, and the + // failure mode is a confusing cascade rather than an honest timeout. + // + // Several suites call `await import("../index.js")` *inside* the test + // body, so Vite's on-demand transform of that module's whole dependency + // graph is charged to the test's budget. That transform cost is + // infrastructure work, not test work, and it varies by more than an order + // of magnitude with cache state and machine load — measured between ~8s + // and ~158s across the suite on the same machine. + // + // When such a test overran 5s, Vitest failed it but did *not* cancel the + // still-running `main()`. The abandoned call kept consuming shared mock + // state — including a `mockResolvedValueOnce` queued by the *next* test — + // so the following test failed with a bogus assertion error that pointed + // nowhere near the real cause. Budget for the transform instead; a genuine + // hang still fails, just 30s later. + testTimeout: 30_000, + hookTimeout: 30_000, }, }); From 61f5acc211f7b381078dc85b23d33a21f9ffe548 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 31 Jul 2026 22:42:08 +0100 Subject: [PATCH 04/42] fix(compile): guard the front-matter capability enum against the proxy catalog `AdoReadCapability` (front matter) was a hand-written copy of `ado_proxy::catalog::Capability` (authoritative) with no mapping between them and nothing asserting they agree. That is precisely the drift the generate- don't-duplicate contract removes between Rust and TypeScript, left open inside Rust: adding a capability to the catalog would ship a proxy enforcing a policy authors have no way to request, and renaming one would silently change the accepted YAML. Give the catalog a `Capability::ALL` plus `is_always_on`, make `AdoReadCapability::to_catalog` the single mapping point, and add a test that fails if the catalog gains a selectable capability front matter cannot express. Verified non-vacuous: adding a `wiki` capability to the catalog fails with "catalog capability wiki is not reachable from front matter". `discovery` stays deliberately unselectable. `az` and the REST SDKs call `resourceareas`/`connectiondata` before anything else, so a policy without it yields a proxy no supported client can use; offering it as a toggle would imply an author could turn it off and still have something that works. The test asserts that too, so it cannot be added by accident. Also close a widening footgun in the same schema: an `allow` entry naming an organization with no `projects` would have granted every project in that organization as the result of *omitting* a key. Reject it. An empty `repositories` list is still fine - it grants project-scoped reads (builds, pipelines, work items) without any repository-scoped read, so it narrows. The object form remains rejected at compile time, but the structural rules now run on the live path ahead of that rejection, so a scope mistake surfaces on the fixture that contains it rather than lying dormant until the proxy is wired. The rejection message now echoes the requested capabilities. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/network.md | 20 +++++ src/ado_proxy/catalog.rs | 44 +++++++++++ src/compile/common.rs | 40 +++++++--- src/compile/types.rs | 159 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 10 deletions(-) diff --git a/docs/network.md b/docs/network.md index 33aa48b73..2b94d6cc0 100644 --- a/docs/network.md +++ b/docs/network.md @@ -251,6 +251,26 @@ agents. Set `permissions.write` only when you need: Azure DevOps permissions on the underlying identity remain the authorization boundary until the policy proxy described in [`ado-proxy-design.md`](ado-proxy-design.md) is implemented. + + An **object form** of `permissions.read` is reserved for that proxy: + + ```yaml + permissions: + read: + service-connection: my-read-sc + capabilities: [core, repos] # discovery is always on + allow: # beyond the current org/project/repo + - organization: other-org + projects: + - project: Other Project + repositories: [other-repo] # omit for project-scoped reads only + ``` + + It **fails compilation today**, deliberately: accepting it while the proxy + is unwired would silently ignore every restriction it declares, which is + strictly worse than rejecting it. An organization entry with no `projects` + is also rejected, because granting an entire organization by *omitting* a + key is the class of accident this proxy exists to prevent. - **`permissions.write` (optional)**: Mints a write-capable ADO-scoped token used **only** by the executor in Stage 3 (`SafeOutputs` job). Overrides the default `$(System.AccessToken)` for write operations. Never exposed diff --git a/src/ado_proxy/catalog.rs b/src/ado_proxy/catalog.rs index 57782cac2..cc498b277 100644 --- a/src/ado_proxy/catalog.rs +++ b/src/ado_proxy/catalog.rs @@ -67,6 +67,50 @@ pub enum Capability { Boards, } +impl Capability { + /// Every capability the catalog defines. + /// + /// Exhaustive by construction: the `match` below fails to compile if a + /// variant is added without being listed here, which in turn drives the + /// front-matter coverage guard in `crate::compile::types`. + /// + /// Only the front-matter guard consumes this today; the compiler wiring + /// that emits the policy document will be its second caller. + #[allow(dead_code)] + pub const ALL: &'static [Self] = &[ + Self::Discovery, + Self::Core, + Self::Repos, + Self::Pipelines, + Self::Boards, + ]; + + /// Whether the proxy enables this capability regardless of author opt-in. + /// + /// `discovery` is always on: `az` and the REST SDKs call `resourceareas` + /// and `connectiondata` before anything else, so a policy without it would + /// produce a proxy no supported client can actually use. It exposes only + /// service-topology metadata, never repository, pipeline, or work-item + /// content, so it is not a meaningful widening. + #[allow(dead_code)] + pub const fn is_always_on(self) -> bool { + match self { + Self::Discovery => true, + Self::Core | Self::Repos | Self::Pipelines | Self::Boards => false, + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Discovery => "discovery", + Self::Core => "core", + Self::Repos => "repos", + Self::Pipelines => "pipelines", + Self::Boards => "boards", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum HostPolicy { diff --git a/src/compile/common.rs b/src/compile/common.rs index d743cef1d..f70cc2bf0 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -461,20 +461,40 @@ pub fn validate_front_matter_identity(front_matter: &FrontMatter) -> Result<()> /// silently ignore scope/capability restrictions. Fail closed until the proxy /// wiring consumes the policy. pub fn validate_permissions_read_policy(front_matter: &FrontMatter) -> Result<()> { - let explicit_options = front_matter + let Some(options) = front_matter .permissions .as_ref() .and_then(|permissions| permissions.read.as_ref()) - .and_then(crate::compile::types::ReadPermissionConfig::options); + .and_then(crate::compile::types::ReadPermissionConfig::options) + else { + return Ok(()); + }; - if explicit_options.is_some() { - anyhow::bail!( - "permissions.read object form requires the credential-isolated Azure DevOps proxy, \ - which is not enabled in this compiler yet. Use the scalar service-connection \ - shorthand for the current trusted MCP behavior." - ); - } - Ok(()) + // Run the structural rules first even though the object form is refused + // below. They are the rules that will govern the policy document once the + // proxy is wired, so keeping them on the live path means they are exercised + // by every fixture that uses the object form rather than only by unit + // tests — a scope mistake cannot lie dormant until the day we enable it. + options.validate()?; + + // Echo back what was requested. Without this the author cannot tell whether + // the compiler understood their policy or choked on the first key. + let requested = if options.capabilities.is_empty() { + "the default capability set".to_string() + } else { + options + .capabilities + .iter() + .map(|capability| capability.to_catalog().as_str()) + .collect::>() + .join(", ") + }; + + anyhow::bail!( + "permissions.read object form requires the credential-isolated Azure DevOps proxy, \ + which is not enabled in this compiler yet (requested: {requested}). Use the scalar \ + service-connection shorthand for the current trusted MCP behavior." + ) } /// Validate the `variable-groups:` front-matter block (issue #1385). diff --git a/src/compile/types.rs b/src/compile/types.rs index 0175c3d3b..4c10dc1f1 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -1910,7 +1910,43 @@ pub struct ReadPermissionOptions { pub allow: Vec, } +impl ReadPermissionOptions { + /// Cross-field rules the schema alone cannot express. + /// + /// Listing an organization with no projects would grant every project in + /// that organization — a large widening produced by *omitting* a key, which + /// is exactly the accident this proxy exists to prevent. An author who + /// genuinely wants breadth should have to name it. + /// + /// An empty `repositories` list is deliberately allowed: it grants the + /// project-scoped reads without any repository-scoped read, so it narrows + /// rather than widens. + pub fn validate(&self) -> anyhow::Result<()> { + for scope in &self.allow { + if scope.projects.is_empty() { + anyhow::bail!( + "permissions.read.allow entry for organization '{}' lists no projects. \ + Name the projects to allow; an empty list would grant every project in \ + the organization.", + scope.organization.as_str() + ); + } + } + Ok(()) + } +} + /// Coarse catalog groups authors may enable for Stage 1 ADO reads. +/// +/// This is the **author-facing** subset of [`crate::ado_proxy::catalog::Capability`], +/// which is authoritative. It is a separate type only because not every catalog +/// capability is selectable: `discovery` is always on, so offering it as a +/// toggle would imply an author could turn it off and get a working proxy. +/// +/// [`AdoReadCapability::to_catalog`] is the single mapping point, and +/// `front_matter_capabilities_cover_the_catalog` fails if the catalog gains a +/// selectable capability this enum does not expose — so the two cannot drift +/// into silently offering authors less than the proxy enforces. #[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum AdoReadCapability { @@ -1921,11 +1957,43 @@ pub enum AdoReadCapability { Boards, } +impl AdoReadCapability { + /// Project onto the authoritative catalog capability. + /// + /// Exhaustive on purpose: adding a variant here without giving it a catalog + /// counterpart is a compile error rather than a policy document the sidecar + /// would reject at runtime. + pub const fn to_catalog(self) -> crate::ado_proxy::catalog::Capability { + use crate::ado_proxy::catalog::Capability; + match self { + Self::Core => Capability::Core, + Self::Repositories => Capability::Repos, + Self::Pipelines => Capability::Pipelines, + Self::Boards => Capability::Boards, + } + } + + /// Every capability an author may name in front matter. + /// + /// Consumed by the drift guard that keeps this enum aligned with the + /// catalog; the policy-document emitter will be its second caller. + #[allow(dead_code)] + pub const ALL: &'static [Self] = &[ + Self::Core, + Self::Repositories, + Self::Pipelines, + Self::Boards, + ]; +} + /// Explicit Azure DevOps organization scope. #[derive(Debug, Deserialize, Clone, PartialEq)] #[serde(deny_unknown_fields)] pub struct AdoReadOrganizationScope { pub organization: crate::secure::AdoOrganization, + /// Projects to allow within this organization. + /// + /// Required and non-empty — see [`ReadPermissionOptions::validate`]. #[serde(default)] pub projects: Vec, } @@ -1935,6 +2003,11 @@ pub struct AdoReadOrganizationScope { #[serde(deny_unknown_fields)] pub struct AdoReadProjectScope { pub project: crate::secure::AdoProject, + /// Repositories to allow within this project. + /// + /// May be omitted. Unlike an organization with no projects, this is not a + /// silent widening: it grants the project-scoped reads (pipelines, builds, + /// work items) without granting any repository-scoped read. #[serde(default)] pub repositories: Vec, } @@ -4013,6 +4086,92 @@ read: } } + /// The author-facing capability enum is a hand-written projection of the + /// authoritative catalog. Nothing but this test stops the catalog from + /// gaining a selectable capability that authors can never enable — the + /// proxy would enforce a policy the front matter cannot express. + #[test] + fn front_matter_capabilities_cover_the_catalog() { + use crate::ado_proxy::catalog::Capability; + + let selectable: Vec = AdoReadCapability::ALL + .iter() + .map(|capability| capability.to_catalog()) + .collect(); + + for capability in Capability::ALL { + if capability.is_always_on() { + assert!( + !selectable.contains(capability), + "{} is always on and must not be offered as a front-matter toggle, \ + or authors will believe they can disable it", + capability.as_str() + ); + continue; + } + assert!( + selectable.contains(capability), + "catalog capability {} is not reachable from front matter; add it to \ + AdoReadCapability so authors can enable what the proxy enforces", + capability.as_str() + ); + } + } + + /// Guards the wire format in the other direction: the YAML an author writes + /// must deserialize to the capability whose name they used. + #[test] + fn front_matter_capability_names_match_the_catalog() { + for capability in AdoReadCapability::ALL { + let name = capability.to_catalog().as_str(); + let yaml = format!("read:\n service-connection: sc\n capabilities: ['{name}']"); + let parsed: PermissionsConfig = serde_yaml::from_str(&yaml) + .unwrap_or_else(|error| panic!("capability {name} must parse: {error}")); + assert_eq!( + parsed.read.as_ref().unwrap().options().unwrap().capabilities, + vec![*capability], + "front-matter name for {name} does not round-trip" + ); + } + } + + /// The `allow` list must not widen to a whole organization by omission. + #[test] + fn read_policy_rejects_an_organization_with_no_projects() { + let yaml = "read:\n service-connection: sc\n allow:\n - organization: other-org"; + let parsed: PermissionsConfig = serde_yaml::from_str(yaml).unwrap(); + let error = parsed + .read + .as_ref() + .unwrap() + .options() + .unwrap() + .validate() + .expect_err("an organization with no projects must be rejected"); + assert!( + error.to_string().contains("lists no projects"), + "error should name the cause: {error}" + ); + } + + /// A project with no repositories narrows rather than widens, so it stands. + #[test] + fn read_policy_allows_a_project_without_repositories() { + let yaml = "read:\n service-connection: sc\n allow:\n - organization: other-org\n projects:\n - project: Other Project"; + let parsed: PermissionsConfig = serde_yaml::from_str(yaml).unwrap(); + assert!( + parsed + .read + .as_ref() + .unwrap() + .options() + .unwrap() + .validate() + .is_ok(), + "project-scoped reads without repository access must be permitted" + ); + } + #[test] fn test_permissions_write_only() { let yaml = "write: my-write-sc"; From 8f99b90788d303ad7d0db40b8ae782183fcdcec7 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sat, 1 Aug 2026 09:17:23 +0100 Subject: [PATCH 05/42] docs(ado-proxy): revise design for per-client ingress; add SPS discovery route Driving the real Azure CLI against the implemented engine invalidated the container-wide interception model the design assumed. Measured: with the OS trust store updated and nothing else, `az` still fails CERTIFICATE_VERIFY_FAILED, because Python's requests uses its own bundled certifi/cacert.pem. Node ignores the OS store too, and the remedies (REQUESTS_CA_BUNDLE, SSL_CERT_FILE) replace rather than extend the bundle, so they must carry the public roots or every non-ADO HTTPS request breaks. Each further runtime needs its own handling, so the mechanism never converges - and the end state plants a CA trusted for every host by every process in the agent for the whole run, to police two hostnames. Two findings give a better shape: - `az` honours an arbitrary base URL. Pointed at https://localhost:/ it issued OPTIONS //_apis then GET //_apis/projects to that endpoint, verifying TLS from REQUESTS_CA_BUNDLE alone with no trust store touched. So it can be *told* where to go rather than deceived about a public hostname - the same trick AWF's existing cli-proxy uses for `gh` via GH_HOST. - The MCP cannot be told: src/index.ts hardcodes "https://dev.azure.com/" + orgName with no override, and 8 raw fetch() call sites ignore proxy env vars regardless (undici needs NODE_USE_ENV_PROXY, which needs Node >=24.5 against a pinned node:20-slim). A DNS alias redirects it at resolution time, which defeats both problems at once. So ingress is per client and trust is scoped to match - one process for `az`, one container for the MCP - while both terminate at the same catalog, keeping a single place where "what may be read" is decided. Enforcement comes from topology rather than client cooperation: Squid denies the protected hosts, so a client that ignores its configuration fails rather than escaping. Certificate trust therefore becomes an availability control, not a security one. Also adds the SPS discovery route the probe proved is required: `az repos show` calls OPTIONS https://app.vssps.visualstudio.com/_apis before its first data call and fails outright without it. It returns service topology only, so it does not widen data access. scripts/az-probe.mjs is the harness behind these findings - it runs the real az through the real bundle against a fake Squid and a fake Azure DevOps with a canary bearer. It should become a conformance test rather than staying a one-off. Open questions are recorded in the doc rather than resolved: whether the SPS call is avoidable with a faithful discovery document, whether Docker's embedded DNS reliably wins for a public FQDN alias, how the engine obtains egress given AWF's DOCKER-USER rules, and whether the MCP can start without npm registry access. No runtime behaviour changes; runtime_available stays false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 213 ++++++++-- .../ado-script/src/ado-proxy/catalog.gen.json | 15 + scripts/az-probe.mjs | 393 ++++++++++++++++++ src/ado_proxy/catalog.rs | 22 + 4 files changed, 606 insertions(+), 37 deletions(-) create mode 100644 scripts/az-probe.mjs diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index 565dd1463..46d72001f 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -64,30 +64,119 @@ ado-aw uses AWF `--network-isolation`. `awf-net` is an internal Docker network; the Agent has no direct internet route and there is no legacy iptables/DNAT fallback. -The target path is policy-first: +**Enforcement comes from topology, not from client cooperation.** Squid denies +the protected Azure DevOps hosts to the Agent, so the only route to them is +through the policy engine. A client that is misconfigured, ignores proxy +environment variables, or declines to trust the interception certificate does +not reach Azure DevOps unpoliced — it fails. + +That gives a useful property: certificate trust is an **availability** control, +not a security one. It decides whether a given client *succeeds* or *fails +closed*. It never decides whether policy applies. This is what allows trust to +be distributed narrowly, per client, instead of container-wide. + +### Ingress is per client; policy is shared + +Different clients reach the policy engine by different means, because they +differ in what they can be told. All of them terminate at the same catalog, so +there is exactly one place where "what may be read" is decided. + +| Client | Ingress | Certificate trust scope | +|---|---|---| +| Azure CLI (`az`) | RPC broker: an agent-side wrapper forwards argv to a sidecar that runs the real `az`, pointed at the policy engine with `--organization https:///` | the `az` process only | +| Azure DevOps MCP | container attached only to an internal network where `dev.azure.com` is a DNS alias for the policy engine | the MCP container only | +| Hand-rolled `curl` / SDK calls from the Agent | none — Squid denies the protected hosts | none; fails closed | + +**Why `az` uses the broker.** `az` accepts an arbitrary base URL, so it can be +*told* to talk to the policy engine rather than deceived about a public +hostname. Verified: pointed at `https://localhost:/`, `az devops +project list` issued `OPTIONS //_apis` followed by `GET +//_apis/projects` to that endpoint, with TLS verified against a +certificate trusted only by that process. No public hostname is impersonated +and no CA is installed anywhere. This mirrors AWF's existing `cli-proxy` +sidecar, which relocates `gh` into a sidecar holding `GH_TOKEN` and points it +at a guard via `GH_HOST`. + +**Why the MCP uses a DNS alias.** It cannot be told. `@azure-devops/mcp` +derives its base URL as `"https://dev.azure.com/" + orgName` with no override, +so redirection has to happen at name resolution. A Docker network alias also +covers the server's raw `fetch()` call sites, which ignore proxy environment +variables regardless of configuration, and therefore removes any dependency on +`NODE_USE_ENV_PROXY` (unavailable on the pinned Node 20 image). + +The alias must live on a network that Squid is **not** attached to. Squid +resolves `dev.azure.com` for the policy engine's own egress; if it saw the +alias, that egress would loop back to the engine. + +### Attack surface this leaves + +- The Agent may still address the policy engine directly on the internal + network. That is safe by construction: the engine applies the same policy to + every caller, is not a generic relay, and can reach the internet only through + Squid. +- `az rest` and `az devops invoke` can express arbitrary REST calls. Command + -level filtering alone does not contain them; they are contained because they + travel the same base URL and are therefore subject to the catalog. +- Requests the client sends to a host outside the protected set are not + policed by the catalog and are not intended to be. Squid's domain policy is + the control there, exactly as for any other traffic. -1. AWF points Agent HTTP(S) proxy variables at a hardened managed sidecar. -2. The sidecar MITMs only compiler-owned Azure DevOps REST hosts. -3. Non-Azure-DevOps traffic is tunneled unchanged to Squid. -4. Approved Azure DevOps requests are also sent upstream through Squid. -5. Squid source ACLs deny protected Azure DevOps destinations when the Agent - tries to address Squid directly, but allow them from the policy sidecar. -6. Clearing proxy variables or opening a direct internet socket has no route. +## Authentication and TLS -The sidecar is safe even when directly reachable from another `awf-net` peer: -it applies the same policy to every caller, is not a generic relay, and can -reach the internet only through Squid. +The Agent never holds an Azure DevOps credential. The policy engine removes all +client-supplied authorization and injects the current bearer only after a +request matches an allowed operation and resource scope. -## Authentication and TLS +### Certificate strategy, per client + +The engine mints certificates at startup; private keys exist only on its own +tmpfs. What differs per client is *which name* is certified and *who trusts it*. + +**`az` — a certificate for an endpoint we own.** The broker points `az` at the +engine's own hostname, so the certificate is issued for that name rather than +for `dev.azure.com`. Trust is supplied to the single `az` process via +`REQUESTS_CA_BUNDLE`. Nothing impersonates a public hostname, and no trust +anchor is installed in any trust store. + +**MCP — an interception certificate for `dev.azure.com`.** Because the base URL +is hardcoded, the engine must present a certificate for the real name, served +by SNI. Trust is installed **only in the MCP container**, whose image, network, +and environment we fully control. + +This split matters. A CA trusted container-wide is trusted for *every* host by +*every* process for the whole run; scoping it to one container bounds that to a +single, purpose-built process. The engine also mints leaves only for protected +hosts, so even within that container it cannot impersonate anything else. -The Agent receives at most a fixed non-secret sentinel needed for client-side -preflight. The proxy removes all client authorization and injects the current -ADO bearer only after the request matches an allowed operation and resource -scope. +### Why not install the CA container-wide -The proxy creates an ephemeral interception CA. Its private key exists only on -sidecar tmpfs. AWF installs only the public certificate into supported client -trust stores. +The earlier design installed one CA into the Agent's trust stores. It was +rejected on evidence: + +- There is no single trust store. `update-ca-certificates` covers curl, git, + Go, and .NET, but Python's `requests` uses its own bundled + `certifi/cacert.pem` and Node ignores the OS store entirely. Measured: with + the OS store updated and nothing else, `az` still fails + `CERTIFICATE_VERIFY_FAILED`. +- The remedies are themselves sharp. `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE` + *replace* rather than extend the bundle, so they must carry the public roots + too or every non-Azure-DevOps HTTPS request breaks. + `NODE_EXTRA_CA_CERTS` takes a single path, so it must be concatenated with + any ssl-bump CA rather than overwritten. +- Each additional runtime — Go, Java, .NET — needs its own handling, so the + mechanism does not converge. + +Per-client scoping avoids all of this and yields a smaller blast radius. + +### Upstream leg + +The engine verifies the real Azure DevOps certificate normally; +`rejectUnauthorized` is never disabled. Interception is trusted at both ends +rather than bypassed at either. This is load-bearing and observable: during +testing the engine correctly refused a self-signed upstream with `unable to +verify the first certificate`. + +### Credential renewal Production must support WIF renewal beyond the original assertion lifetime. The expected trusted path requests a fresh assertion from @@ -151,20 +240,24 @@ closed. Request handling has exactly two paths: -- **Non-protected destination.** For `CONNECT`, the proxy opens a tunnel - through Squid and byte-tunnels in both directions. It does not terminate - TLS, parse the payload, or touch the client's own credentials, so package - feeds and every other allowed host behave exactly as they do without the - sidecar. Absolute-form plain HTTP is relayed to Squid unchanged, because the - agent's `HTTP_PROXY` points here and refusing cleartext would silently break - `http://` package sources. -- **Protected destination.** The proxy terminates TLS with an ephemeral leaf - (ALPN pinned to `http/1.1`), normalizes the request, evaluates it against - the versioned catalog, drops every client credential and forwarding header, - and — only after a complete allow decision, and only for a protected - upstream — attaches the current bearer and sends the request through Squid. - Plain HTTP to a protected host, and `CONNECT` to a protected host on any - port other than 443, are denied outright. +- **Direct TLS on the protected path.** Both the broker (`az`) and the + DNS-aliased MCP connect straight to the engine on 443. It terminates TLS with + a leaf selected by SNI (ALPN pinned to `http/1.1`), normalizes the request, + evaluates it against the versioned catalog, drops every client credential and + forwarding header, and — only after a complete allow decision, and only for a + protected upstream — attaches the current bearer and forwards through Squid. +- **`CONNECT` for proxy-style clients.** Retained for clients configured with + `HTTPS_PROXY`. Protected destinations are intercepted as above; non-protected + destinations are byte-tunnelled to Squid untouched, so package feeds behave + exactly as they do without the sidecar. Plain HTTP to a protected host, and + `CONNECT` to a protected host on any port other than 443, are denied. + +> **Superseded.** Earlier drafts made `CONNECT` the *only* ingress by pointing +> the Agent's `HTTPS_PROXY` at the engine, which put it on the path for all +> traffic. Under the per-client model the engine sees only Azure DevOps +> traffic; everything else keeps its existing route to Squid and is provably +> unaffected. The byte-tunnel path is therefore a compatibility affordance +> rather than the primary design, and can be removed if no client needs it. Request normalization is deliberately strict rather than lenient: a target that would need rewriting to become safe is refused instead, so the bytes the @@ -216,12 +309,58 @@ Custody rules the implementation enforces: supplied and the proxy stripped. Raw paths, query values, headers, bodies, and credentials have nowhere to go in the record type. +## Evidence + +Findings from driving the real Azure CLI against the implemented engine. These +are what moved the design from container-wide interception to per-client +ingress; they are recorded so the reasoning can be re-checked rather than +re-derived. + +| Claim | Evidence | +|---|---| +| `az` honours a non-`dev.azure.com` base URL | Pointed at `https://localhost:/`, it issued `OPTIONS //_apis` then `GET //_apis/projects` to that endpoint | +| Per-process trust is sufficient for `az` | The above verified TLS using `REQUESTS_CA_BUNDLE` alone, with no trust store modified | +| OS trust store alone is **not** sufficient | `az` fails `CERTIFICATE_VERIFY_FAILED`; Python `requests` uses its own `certifi/cacert.pem` | +| The MCP cannot be redirected by configuration | `src/index.ts`: `const orgUrl = "https://dev.azure.com/" + orgName`, no env override | +| The MCP would partially bypass a proxy-env-var approach | 8 raw `fetch()` call sites; undici ignores `HTTP(S)_PROXY` without `NODE_USE_ENV_PROXY`, which needs Node ≥24.5 against a pinned `node:20-slim` | +| `az` reaches a hardcoded SPS host | `OPTIONS`/`GET` to `app.vssps.visualstudio.com` not redirected by `--organization` — see open questions | +| Upstream verification is real | The engine refused a self-signed upstream with `unable to verify the first certificate` | +| Denials surface usefully to clients | `az` printed the engine's `WrappedException` message verbatim | + +The harness is `scripts/az-probe.mjs`. It stands up a fake Squid and a fake +Azure DevOps, runs the real `az` through the real bundle with a canary bearer, +and asserts both that allowed reads carry the injected credential and that +denials never reach the upstream. It should become the basis of a conformance +test rather than remaining a one-off. + +## Open questions + +These gate implementation and are unresolved at the time of writing: + +1. **Is the SPS call avoidable?** `az` contacted `app.vssps.visualstudio.com` + despite a custom base URL. This is likely an artifact of the probe serving an + incomplete resource-location document, since that document — which the engine + controls in the broker model — is what tells `az` where each area lives. If + it is *not* avoidable, the sidecar reaches real SPS directly. That is + probably acceptable, as SPS returns service topology rather than project + data, but it must be a decision rather than an accident. +2. **Does Docker's embedded DNS reliably win for a public FQDN alias?** The MCP + path depends entirely on this and it has not been tested on a real runner. +3. **How does the engine obtain egress?** AWF's `DOCKER-USER` rules block the + default bridge — the reason the MCP runs `--network host` today — so it needs + `awf-net`. Whether that attachment can be made from the pipeline, or requires + AWF to own the sidecar's lifecycle, determines how much of the AWF change is + avoidable. +4. **Does the MCP still start without npm registry access?** `npx -y + @azure-devops/mcp` resolves at spawn time. Pre-baking the image removes this + dependency and is preferable on supply-chain grounds regardless. + ## Production gates Default-on rollout requires evidence that: -- stock `az`, curl, Python clients, and the ADO MCP can perform allowed scoped - reads with no real client credential; +- stock `az` and the ADO MCP can perform allowed scoped reads with no real + client credential; - write, cross-scope, sensitive, unknown, alternate-host, direct-Squid, and direct-socket requests do not reach the upstream operation; - WIF renewal works after the original assertion expires; @@ -229,5 +368,5 @@ Default-on rollout requires evidence that: artifacts; - package restore and non-ADO network behavior remain intact; - all compile targets emit the same boundary; -- a released, pinned AWF image implements the managed proxy/CA path and required - internal mirrors contain that image. +- a released, pinned AWF image implements the required sidecar and network + wiring, and internal mirrors contain that image. diff --git a/scripts/ado-script/src/ado-proxy/catalog.gen.json b/scripts/ado-script/src/ado-proxy/catalog.gen.json index a34227ed2..ce3ef1641 100644 --- a/scripts/ado-script/src/ado-proxy/catalog.gen.json +++ b/scripts/ado-script/src/ado-proxy/catalog.gen.json @@ -21,6 +21,21 @@ "denied_query": [], "max_response_bytes": 10485760 }, + { + "id": "discovery.sps-host-options", + "capability": "discovery", + "method": "OPTIONS", + "host": "sps-fallback", + "route": "/_apis", + "api_version": "absent", + "scope": "current-organization", + "response": "json", + "allowed_query": [ + "allHostTypes" + ], + "denied_query": [], + "max_response_bytes": 10485760 + }, { "id": "discovery.area-options", "capability": "discovery", diff --git a/scripts/az-probe.mjs b/scripts/az-probe.mjs new file mode 100644 index 000000000..a679591c4 --- /dev/null +++ b/scripts/az-probe.mjs @@ -0,0 +1,393 @@ +/** + * Live Azure CLI probe against the real `ado-proxy` bundle. + * + * Not a unit test and not part of any suite: it drives the *actual* `az` + * binary through the *actual* bundle to answer a question the mocked E2E + * cannot — does stock tooling work through this proxy, and does the catalog + * match the requests `az` really makes? + * + * Topology (no DNS changes, no real Azure DevOps, no real credential): + * + * az --HTTPS_PROXY--> ado-proxy --CONNECT--> fake Squid --> fake ADO + * + * `az` is given the proxy's public CA via REQUESTS_CA_BUNDLE, so TLS + * verification stays ON throughout — this proves interception is *trusted*, + * not bypassed. The bearer the proxy injects is a canary string; the fake + * upstream records every request so we can diff what `az` asked for against + * what the catalog allows. + * + * Usage: node scripts/az-probe.mjs + */ +import { execFileSync, spawn } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer as createHttpServer } from "node:http"; +import { connect as netConnect } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer as createTlsServer } from "node:tls"; + +const here = dirname(fileURLToPath(import.meta.url)); +const bundle = join(here, "ado-script", "ado-proxy.js"); + +const CANARY = "canary-bearer-probe-9f3a2c"; +const ORG = "contoso"; +const PROJECT = "Widgets"; +const REPO = "widget-api"; + +// Git for Windows ships openssl but does not export it. +for (const dir of ["C:\\Program Files\\Git\\usr\\bin", "C:\\Program Files\\Git\\mingw64\\bin"]) { + try { + execFileSync("openssl", ["version"], { stdio: "ignore" }); + break; + } catch { + process.env.PATH = `${dir};${process.env.PATH ?? ""}`; + } +} + +const work = mkdtempSync(join(tmpdir(), "az-probe-")); +const servers = []; +/** Every request the fake Azure DevOps upstream actually received. */ +const upstreamCalls = []; +/** Everything the proxy allowed or denied, parsed from its decision log. */ +const proxyDecisions = []; + +function listen(server, port = 0) { + return new Promise((resolve) => { + server.listen(port, "127.0.0.1", () => resolve(server.address().port)); + }); +} + +function mintCa(dir, hosts) { + const list = Array.isArray(hosts) ? hosts : [hosts]; + const primary = list[0]; + execFileSync( + "openssl", + ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", "-subj", `/CN=${primary} CA`, + "-keyout", "ca.key", "-out", "ca.pem", + "-addext", "basicConstraints=critical,CA:TRUE,pathlen:0"], + { cwd: dir, stdio: ["ignore", "ignore", "pipe"] }, + ); + const san = list.map((host) => `DNS:${host}`).join(","); + writeFileSync(join(dir, "leaf.ext"), + `basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=${san}\n`); + execFileSync("openssl", ["req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", `/CN=${primary}`, + "-keyout", "leaf.key", "-out", "leaf.csr"], { cwd: dir, stdio: ["ignore", "ignore", "pipe"] }); + execFileSync("openssl", ["x509", "-req", "-in", "leaf.csr", "-CA", "ca.pem", "-CAkey", "ca.key", + "-CAcreateserial", "-days", "2", "-extfile", "leaf.ext", "-out", "leaf.pem"], + { cwd: dir, stdio: ["ignore", "ignore", "pipe"] }); + return { + key: readText(join(dir, "leaf.key")), + cert: readText(join(dir, "leaf.pem")), + ca: readText(join(dir, "ca.pem")), + }; +} + +function readText(path) { + return execFileSync(process.execPath, ["-e", `process.stdout.write(require('node:fs').readFileSync(${JSON.stringify(path)},'utf8'))`], { encoding: "utf8" }); +} + +/** + * The `OPTIONS /_apis` discovery document. + * + * Host-aware, because the two protected hosts serve different things and + * conflating them makes `az` address the wrong one: + * + * - **SPS** (`app.vssps.visualstudio.com`) is a deployment-level service. It + * advertises the *location* service, which is how `az` discovers which host + * owns an area. Advertising org-level resources here would make `az` ask + * SPS for `/_apis/projects`, which real Azure DevOps never serves — and + * would tempt us into widening the catalog to fit a harness bug. + * - **Organization host** (`dev.azure.com`) advertises the org-level + * resources `az` actually reads. + */ +function resourceLocations(host) { + const entry = (id, area, resourceName, routeTemplate) => ({ + id, + area, + resourceName, + routeTemplate, + resourceVersion: 1, + minVersion: "1.0", + maxVersion: "7.2", + releasedVersion: "7.1", + }); + + if (host.startsWith("app.vssps")) { + const value = [ + entry("e81700f7-3be2-46de-8624-2eb35882fcaa", "location", "resourceAreas", "_apis/{resource}/{areaId}"), + ]; + return { count: value.length, value }; + } + + const value = [ + entry("603fe2ac-9723-48b9-88ad-09305aa6c6e1", "core", "projects", "_apis/{resource}/{*projectId}"), + entry("225f7195-f9c7-4d14-ab28-a83f7ff77e1f", "git", "repositories", "{project}/_apis/git/{resource}/{repositoryId}"), + entry("dbeaf647-6167-421a-bda9-c9327b25e2e6", "build", "builds", "{project}/_apis/build/{resource}/{buildId}"), + ]; + return { count: value.length, value }; +} + +/** Realistic Azure DevOps responses for the routes az actually calls. */ +function respond(url, method, host, response) { + const path = url.split("?")[0].toLowerCase(); + const json = (body) => { + const text = JSON.stringify(body); + response.writeHead(200, { "content-type": "application/json", "content-length": Buffer.byteLength(text) }); + response.end(text); + }; + + if (method === "OPTIONS") return json(resourceLocations(host)); + + // The location service: every area resolves to the organization host, which + // is what sends az back to dev.azure.com for real data. + if (path.includes("/_apis/resourceareas")) { + if (path.endsWith("/resourceareas")) { + return json({ count: 1, value: [{ id: "79134c72-4a58-4b42-976c-04e7115f32bf", name: "git", locationUrl: `https://dev.azure.com/${ORG}/` }] }); + } + return json({ id: path.split("/").pop(), name: "git", locationUrl: `https://dev.azure.com/${ORG}/` }); + } + + if (path.endsWith("/_apis/projects")) { + return json({ + count: 2, + value: [ + { id: "11111111-1111-1111-1111-111111111111", name: PROJECT, state: "wellFormed", visibility: "private" }, + { id: "33333333-3333-3333-3333-333333333333", name: "Secrets", state: "wellFormed", visibility: "private" }, + ], + }); + } + if (path.includes("/_apis/connectiondata")) { + return json({ authenticatedUser: { id: "0", providerDisplayName: "probe" }, instanceId: "x", deploymentId: "y" }); + } + if (path.includes(`/_apis/git/repositories/${REPO.toLowerCase()}`)) { + return json({ + id: "22222222-2222-2222-2222-222222222222", + name: REPO, + project: { id: "11111111-1111-1111-1111-111111111111", name: PROJECT }, + defaultBranch: "refs/heads/main", + }); + } + if (path.includes("/_apis/build/builds")) { + return json({ count: 0, value: [] }); + } + return json({ count: 0, value: [] }); +} + +async function start() { + // --- fake Azure DevOps ------------------------------------------------- + const adoDir = join(work, "ado"); + execFileSync(process.execPath, ["-e", `require('node:fs').mkdirSync(${JSON.stringify(adoDir)},{recursive:true})`]); + const adoCert = mintCa(adoDir, ["dev.azure.com", "app.vssps.visualstudio.com"]); + + const adoApp = createHttpServer((request, response) => { + upstreamCalls.push({ + method: request.method, + url: request.url, + authorization: request.headers.authorization ?? "(none)", + accept: request.headers.accept ?? "(none)", + }); + respond(request.url, request.method, response); + }); + const adoTls = createTlsServer({ key: adoCert.key, cert: adoCert.cert }); + adoTls.on("secureConnection", (socket) => adoApp.emit("connection", socket)); + servers.push(adoTls); + const adoPort = await listen(adoTls); + + // --- fake Squid: the proxy's only route out ---------------------------- + const squid = createHttpServer((_request, response) => response.writeHead(405).end()); + squid.on("connection", (socket) => { + console.log(` [squid] TCP connection from ${socket.remoteAddress}:${socket.remotePort}`); + }); + squid.on("connect", (request, clientSocket, head) => { + console.log(` [squid] CONNECT ${request.url}`); + // Both protected hosts resolve to the one fake upstream, whose certificate + // carries a SAN for each. + if (!["dev.azure.com:443", "app.vssps.visualstudio.com:443"].includes(request.url)) { + clientSocket.end("HTTP/1.1 403 Forbidden\r\n\r\n"); + return; + } + const upstream = netConnect({ host: "127.0.0.1", port: adoPort }, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) upstream.write(head); + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + upstream.on("error", (error) => { + console.log(` [squid] upstream error: ${error.message}`); + clientSocket.destroy(); + }); + clientSocket.on("error", () => upstream.destroy()); + }); + squid.on("clientError", (error) => console.log(` [squid] clientError: ${error.message}`)); + servers.push(squid); + const squidPort = await listen(squid); + + // --- the real ado-proxy bundle ---------------------------------------- + const policy = { + catalog_version: "ado-aw/ado-proxy-catalog/v1", + organization: ORG, + project: PROJECT, + project_id: "11111111-1111-1111-1111-111111111111", + repository: REPO, + repository_id: "22222222-2222-2222-2222-222222222222", + capabilities: ["discovery", "core", "repos", "pipelines", "boards"], + protected_hosts: ["dev.azure.com", "app.vssps.visualstudio.com"], + allowed_resource_areas: ["79134c72-4a58-4b42-976c-04e7115f32bf"], + }; + writeFileSync(join(work, "policy.json"), JSON.stringify(policy)); + writeFileSync(join(work, "token"), CANARY); + const caOut = join(work, "proxy-ca.pem"); + writeFileSync(caOut, ""); + + const proxyPort = 18080; + console.log(` [harness] squid=${squidPort} fakeAdo=${adoPort} proxy=${proxyPort}`); + const adoCaFile = join(work, "fake-ado-ca.pem"); + writeFileSync(adoCaFile, adoCert.ca); + const proxy = spawn(process.execPath, [bundle, + "--policy-file", join(work, "policy.json"), + "--token-file", join(work, "token"), + "--public-ca-file", caOut, + "--upstream-proxy", `http://127.0.0.1:${squidPort}`, + "--listen-address", "127.0.0.1", + "--listen-port", String(proxyPort), + "--log-dir", join(work, "log"), + ], { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + // The proxy verifies the *upstream* certificate and correctly refuses a + // self-signed one ("unable to verify the first certificate"). Trust the + // harness's fake-ADO CA so the probe can proceed — verification stays on, + // this only adds one CA. Nothing in the bundle disables it. + NODE_EXTRA_CA_CERTS: adoCaFile, + }, + }); + + proxy.stderr.on("data", (chunk) => process.stdout.write(` [proxy] ${chunk}`)); + await new Promise((resolve) => setTimeout(resolve, 2500)); + + return { proxyPort, caOut, proxy }; +} + +/** + * Resolve the `az` entry point. + * + * On Windows `az` is a `.cmd` shim, which `execFileSync` cannot spawn + * directly. Prefer the Python entry point when we can find it so the child is + * a real executable rather than a shell. + */ +function resolveAz() { + const command = process.platform === "win32" ? "where" : "which"; + try { + const found = execFileSync(command, ["az"], { encoding: "utf8" }) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const cmd = found.find((path) => path.toLowerCase().endsWith(".cmd")) ?? found[0]; + return cmd; + } catch { + return "az"; + } +} + +const AZ = resolveAz(); + +/** + * Run `az` **asynchronously**. + * + * This must not be `execFileSync`. The fake Squid and fake Azure DevOps servers + * live in *this* process, so a synchronous child blocks the event loop and they + * can never accept a connection — the proxy then reports "timed out opening a + * tunnel" and nothing reaches the upstream. The proxy itself is a separate + * process, which is why `az -> proxy` worked while `proxy -> squid` did not. + */ +function runAz(args, env) { + const useShell = process.platform === "win32"; + // With `shell: true` the command and args are joined into one shell string, + // so a path containing spaces ("C:\Program Files\...") must be quoted. + const command = useShell ? `"${AZ}"` : AZ; + return new Promise((resolve) => { + const child = spawn(command, args, { env, shell: useShell }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const timer = setTimeout(() => child.kill(), 180_000); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ ok: code === 0, code, stdout, stderr }); + }); + child.on("error", (error) => { + clearTimeout(timer); + resolve({ ok: false, code: -1, stdout, stderr: String(error) }); + }); + }); +} + +const { proxyPort, caOut, proxy } = await start(); + +const azEnv = { + ...process.env, + HTTPS_PROXY: `http://127.0.0.1:${proxyPort}`, + HTTP_PROXY: `http://127.0.0.1:${proxyPort}`, + // TLS verification stays ON — az must *trust* the interception CA. + REQUESTS_CA_BUNDLE: caOut, + // What an author would have set today; the proxy must strip it. + AZURE_DEVOPS_EXT_PAT: "sentinel-pat-must-not-reach-upstream", + AZURE_CORE_COLLECT_TELEMETRY: "no", + AZURE_CORE_ONLY_SHOW_ERRORS: "true", +}; + +const scenarios = [ + ["project list (in scope)", ["devops", "project", "list", "--organization", `https://dev.azure.com/${ORG}`, "-o", "json"]], + ["repo show (in scope)", ["repos", "show", "--repository", REPO, "--organization", `https://dev.azure.com/${ORG}`, "--project", PROJECT, "-o", "json", "--debug"]], +]; + +for (const [label, args] of scenarios) { + console.log(`\n=== az ${label} ===`); + const before = upstreamCalls.length; + const result = await runAz(args, azEnv); + console.log(` exit code: ${result.code}`); + writeFileSync(join(process.cwd(), `az-${label.split(" ")[0]}-stderr.log`), result.stderr ?? ""); + if (!result.ok) console.log(` stderr: ${(result.stderr || "(empty)").split("\n").filter((l) => l.includes("ERROR")).slice(0, 4).join("\n ")}`); + else console.log(` stdout: ${result.stdout.slice(0, 300).replace(/\s+/g, " ")}`); + console.log(` upstream requests: ${upstreamCalls.length - before}`); +} + +console.log("\n================ REQUESTS THAT REACHED THE FAKE ADO ================"); +for (const call of upstreamCalls) { + console.log(` ${call.method} ${call.url}`); + console.log(` auth: ${call.authorization}`); + console.log(` accept: ${call.accept}`); +} + +console.log("\n================ PROXY DECISION LOG ================"); +try { + const log = readText(join(work, "log", "ado-proxy-decisions.jsonl")); + for (const line of log.split("\n").filter(Boolean)) { + const record = JSON.parse(line); + if (record.schema) continue; + proxyDecisions.push(record); + console.log(` ${record.decision.toUpperCase().padEnd(5)} ${record.method} ${record.operation ?? record.reason ?? ""} ${record.detail ? `— ${record.detail}` : ""}`); + } +} catch (error) { + console.log(` (no decision log: ${error.message})`); +} + +console.log("\n================ CANARY CHECK ================"); +const leaked = upstreamCalls.some((call) => call.authorization.includes("sentinel-pat")); +const injected = upstreamCalls.some((call) => call.authorization === `Bearer ${CANARY}`); +console.log(` sentinel PAT reached upstream: ${leaked} (must be false)`); +console.log(` proxy bearer reached upstream: ${injected} (must be true if anything was allowed)`); + +proxy.kill(); +for (const server of servers) server.close(); +setTimeout(() => { + rmSync(work, { recursive: true, force: true }); + process.exit(0); +}, 500); diff --git a/src/ado_proxy/catalog.rs b/src/ado_proxy/catalog.rs index cc498b277..d190892f1 100644 --- a/src/ado_proxy/catalog.rs +++ b/src/ado_proxy/catalog.rs @@ -242,6 +242,28 @@ pub fn operations() -> Vec { denied_query: NO_QUERY, max_response_bytes: JSON_LIMIT, }, + Operation { + id: "discovery.sps-host-options", + capability: Capability::Discovery, + method: HttpMethod::Options, + host: HostPolicy::SpsFallback, + // No `{org}` segment: SPS is a deployment-level service, so this + // route is organization-agnostic. + // + // Verified against the real Azure CLI: `az repos show` issues + // `OPTIONS https://app.vssps.visualstudio.com/_apis` before its + // first data call and fails outright without it. It returns the + // same resource-location document as the organization-host variant + // — service topology only, never repository, pipeline, or work-item + // content — so allowing it does not widen data access. + route: "/_apis", + api_version: API_VERSION_ABSENT, + scope: ScopePolicy::CurrentOrganization, + response: ResponsePolicy::Json, + allowed_query: &["allHostTypes"], + denied_query: NO_QUERY, + max_response_bytes: JSON_LIMIT, + }, Operation { id: "discovery.area-options", capability: Capability::Discovery, From e80596fcd598c3c73d9c4cdba6eb6e14e8dde001 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sat, 1 Aug 2026 22:34:55 +0100 Subject: [PATCH 06/42] test(ado-proxy): prove --add-host redirection and that SPS is avoidable Wave 0 de-risking for the per-client ingress design. Both spikes ran against real Docker (engine 29.6.2, linux/arm64) and the real Azure CLI, so the design now rests on measurement rather than inference. --add-host redirection (scripts/add-host-probe.mjs) --------------------------------------------------- The ADO MCP path depends on redirecting a container that cannot be told where to go: @azure-devops/mcp hardcodes its base URL, and 8 of its call sites use raw fetch(), which ignores proxy env vars. A node:20-slim container given --add-host dev.azure.com: plus NODE_EXTRA_CA_CERTS reached the stand-in proxy over BOTH node:https AND global fetch, with rejectUnauthorized left on, and the server observed Host: dev.azure.com - so the client genuinely believed it was talking to Azure DevOps. A negative control (unrelated host) failed ENOTFOUND, confirming the redirect is narrow. This retires the Docker DNS-alias approach: --add-host needs no DNS at all, which matters because AWF itself falls back to /etc/hosts where embedded DNS is unreachable (gVisor, ARC/DinD). It also retires the Node 20 blocker, since no proxy env var is involved. SPS avoidance (scripts/sps-probe.mjs) -------------------------------------- An earlier probe saw az contact app.vssps.visualstudio.com despite a custom --organization, which would have meant reaching a deployment-level host outside the policy scope. Three scenarios show it is an artifact of the discovery document, not fixed behaviour: minimal doc -> az fails: location area not registered faithful doc + sparse areas -> az falls back to SPS faithful doc + complete areas -> az exit 0, never contacts SPS The third case is the first time az has completed end to end in any probe. This carries a concrete implementation consequence, recorded in the design doc and tracked as proxy-rewrite-areas: the engine must REWRITE every locationUrl in /_apis/resourceAreas to point at itself. The filter-resource-areas policy currently implemented drops entries failing a protected-host check, which would empty the list and send az straight back to the SPS fallback - the opposite of the intent. No runtime behaviour changes; runtime_available stays false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 72 ++++++++---- scripts/add-host-probe.mjs | 221 +++++++++++++++++++++++++++++++++++++ scripts/sps-probe.mjs | 202 +++++++++++++++++++++++++++++++++ 3 files changed, 474 insertions(+), 21 deletions(-) create mode 100644 scripts/add-host-probe.mjs create mode 100644 scripts/sps-probe.mjs diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index 46d72001f..798956a02 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -323,38 +323,68 @@ re-derived. | OS trust store alone is **not** sufficient | `az` fails `CERTIFICATE_VERIFY_FAILED`; Python `requests` uses its own `certifi/cacert.pem` | | The MCP cannot be redirected by configuration | `src/index.ts`: `const orgUrl = "https://dev.azure.com/" + orgName`, no env override | | The MCP would partially bypass a proxy-env-var approach | 8 raw `fetch()` call sites; undici ignores `HTTP(S)_PROXY` without `NODE_USE_ENV_PROXY`, which needs Node ≥24.5 against a pinned `node:20-slim` | -| `az` reaches a hardcoded SPS host | `OPTIONS`/`GET` to `app.vssps.visualstudio.com` not redirected by `--organization` — see open questions | +| **`--add-host` redirects a container to the proxy, TLS verified** | A `node:20-slim` container given `--add-host dev.azure.com:` and `NODE_EXTRA_CA_CERTS` reached the stand-in proxy over **both** `node:https` *and* global `fetch`, with `rejectUnauthorized` left on. Server observed `Host: dev.azure.com`, so the client genuinely believed it was talking to Azure DevOps | +| **The redirect is narrow** | In the same run an unrelated host failed `ENOTFOUND` — only the named host is affected | +| **SPS is avoidable, and `az` completes entirely against the policy endpoint** | Three scenarios (`scripts/sps-probe.mjs`): a *minimal* discovery document fails (`location` area not registered); *faithful* document + a sparse area list falls back to `app.vssps.visualstudio.com`; *faithful* document + a **complete** area list — real area GUIDs, every `locationUrl` pointing back at the endpoint — completed with **exit 0** and never contacted SPS | | Upstream verification is real | The engine refused a self-signed upstream with `unable to verify the first certificate` | | Denials surface usefully to clients | `az` printed the engine's `WrappedException` message verbatim | -The harness is `scripts/az-probe.mjs`. It stands up a fake Squid and a fake -Azure DevOps, runs the real `az` through the real bundle with a canary bearer, -and asserts both that allowed reads carry the injected credential and that -denials never reach the upstream. It should become the basis of a conformance -test rather than remaining a one-off. +Three harnesses produce this evidence and should become conformance tests: + +- `scripts/az-probe.mjs` stands up a fake Squid and a fake Azure DevOps, runs + the real `az` through the real bundle with a canary bearer, and asserts both + that allowed reads carry the injected credential and that denials never reach + the upstream. +- `scripts/add-host-probe.mjs` proves the container-level redirection the MCP + path depends on, including the undici path and the negative control. +- `scripts/sps-probe.mjs` proves which discovery-document shape keeps `az` on + the policy endpoint. + +Both container probes were run on Docker Desktop 29.6.2 (`linux/arm64`). + +### Consequence for the resource-area response + +`az` resolves service locations from `/_apis/resourceAreas`, so that response +determines whether it stays on the policy endpoint: + +- omit the `location` area → `az` fails outright (`API resource location + e81700f7-… is not registered`); +- advertise it but return an incomplete area list → `az` falls back to + deployment-level SPS; +- return the real area GUIDs with every `locationUrl` pointing at the policy + endpoint → `az` completes without ever contacting SPS. + +The engine must therefore **rewrite** `locationUrl` to itself rather than +merely filtering the list. A filter that drops entries not matching a protected +host would empty the list and reintroduce the SPS fallback — the opposite of +the intent. This supersedes the drop-only `filter-resource-areas` behaviour +currently implemented in `response.ts`. ## Open questions These gate implementation and are unresolved at the time of writing: -1. **Is the SPS call avoidable?** `az` contacted `app.vssps.visualstudio.com` - despite a custom base URL. This is likely an artifact of the probe serving an - incomplete resource-location document, since that document — which the engine - controls in the broker model — is what tells `az` where each area lives. If - it is *not* avoidable, the sidecar reaches real SPS directly. That is - probably acceptable, as SPS returns service topology rather than project - data, but it must be a decision rather than an accident. -2. **Does Docker's embedded DNS reliably win for a public FQDN alias?** The MCP - path depends entirely on this and it has not been tested on a real runner. -3. **How does the engine obtain egress?** AWF's `DOCKER-USER` rules block the - default bridge — the reason the MCP runs `--network host` today — so it needs - `awf-net`. Whether that attachment can be made from the pipeline, or requires - AWF to own the sidecar's lifecycle, determines how much of the AWF change is - avoidable. -4. **Does the MCP still start without npm registry access?** `npx -y +1. **How does the engine obtain egress?** AWF's `DOCKER-USER` rules block the + default bridge — the reason the MCP runs `--network host` today. The jump + rule is scoped `-i `, so a container of ours should be + unaffected, but this needs confirming on a real runner. +2. **Does the MCP still start without npm registry access?** `npx -y @azure-devops/mcp` resolves at spawn time. Pre-baking the image removes this dependency and is preferable on supply-chain grounds regardless. +Resolved since the first draft: + +- ~~Does Docker's embedded DNS reliably win for a public FQDN alias?~~ Moot: + `--add-host` is used instead, and is proven above. It needs no DNS at all, + which is why it is preferred — AWF itself falls back to `/etc/hosts` because + embedded DNS is unreachable under gVisor and on ARC/DinD. +- ~~Is the SPS call avoidable?~~ **Yes**, provided the engine returns a + complete resource-area list pointing at itself (see above). SPS therefore + need not be reached at all on the `az` path. The catalog retains + `discovery.sps-host-options` and `discovery.sps-resource-area` as a + defence-in-depth affordance for clients that still fall back; both return + service topology only. + ## Production gates Default-on rollout requires evidence that: diff --git a/scripts/add-host-probe.mjs b/scripts/add-host-probe.mjs new file mode 100644 index 000000000..1680458d2 --- /dev/null +++ b/scripts/add-host-probe.mjs @@ -0,0 +1,221 @@ +/** + * Spike: can `--add-host` redirect a container's Azure DevOps traffic to the + * policy proxy, with TLS verification left ON? + * + * This is the mechanism the ADO MCP path depends on. `@azure-devops/mcp` + * hardcodes `"https://dev.azure.com/" + orgName` with no override, and 8 of its + * HTTP call sites use raw `fetch()`, which ignores proxy environment variables + * (undici honours them only under `NODE_USE_ENV_PROXY`, which needs Node ≥24.5 + * against a pinned `node:20-slim`). So redirection has to happen *below* the + * application, without the client cooperating. + * + * `--add-host` writes `/etc/hosts` directly, so it needs no DNS at all. That + * matters because AWF itself pre-registers `/etc/hosts` entries precisely + * because Docker's embedded DNS is unreachable under gVisor and on ARC/DinD — + * a Docker network alias would be fragile in exactly those environments. + * + * What this proves, or fails to: + * + * 1. a client asking for `https://dev.azure.com/...` reaches our server; + * 2. it does so with `rejectUnauthorized` left ON, trusting only the CA we + * supply via `NODE_EXTRA_CA_CERTS` — i.e. interception is *trusted*, not + * bypassed; + * 3. **both** Node HTTP paths work: `https.get` (typed-rest-client's path) + * and global `fetch` (undici — the one that ignores proxies); + * 4. an unrelated host is *not* redirected, so the mechanism is narrow. + * + * Usage: node scripts/add-host-probe.mjs + */ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const DOCKER_DIR = "C:\\Users\\devinejames\\AppData\\Local\\Programs\\DockerDesktop\\resources\\bin"; +const NETWORK = "ado-proxy-spike-net"; +const SERVER = "ado-proxy-spike-server"; +const IMAGE = "node:20-slim"; +const CANARY = "reached-the-policy-proxy-9f3a"; + +/** Resolve `docker`, which is not on the default shell PATH on this machine. */ +function docker(args, options = {}) { + return execFileSync(join(DOCKER_DIR, "docker.exe"), args, { + encoding: "utf8", + timeout: 180_000, + ...options, + }); +} + +function quietDocker(args) { + try { + return docker(args, { stdio: ["ignore", "pipe", "ignore"] }); + } catch { + return ""; + } +} + +/** Git for Windows ships openssl but does not export it. */ +function ensureOpenssl() { + for (const dir of ["C:\\Program Files\\Git\\usr\\bin", "C:\\Program Files\\Git\\mingw64\\bin"]) { + try { + execFileSync("openssl", ["version"], { stdio: "ignore" }); + return; + } catch { + process.env.PATH = `${dir};${process.env.PATH ?? ""}`; + } + } + execFileSync("openssl", ["version"], { stdio: "ignore" }); +} + +const work = mkdtempSync(join(tmpdir(), "add-host-spike-")); +let cleanupNeeded = true; + +function cleanup() { + if (!cleanupNeeded) return; + cleanupNeeded = false; + quietDocker(["rm", "-f", SERVER]); + quietDocker(["network", "rm", NETWORK]); + rmSync(work, { recursive: true, force: true }); +} + +process.on("exit", cleanup); + +try { + ensureOpenssl(); + + // ── certificate for dev.azure.com ────────────────────────────────────── + // The leaf carries a SAN for the real hostname: the client must be able to + // verify it *as* dev.azure.com, which is the whole point of interception. + execFileSync("openssl", [ + "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", + "-subj", "/CN=ado-proxy spike CA", "-keyout", "ca.key", "-out", "ca.pem", + "-addext", "basicConstraints=critical,CA:TRUE,pathlen:0", + ], { cwd: work, stdio: ["ignore", "ignore", "pipe"] }); + + writeFileSync(join(work, "leaf.ext"), + "basicConstraints=CA:FALSE\n" + + "keyUsage=critical,digitalSignature,keyEncipherment\n" + + "extendedKeyUsage=serverAuth\n" + + "subjectAltName=DNS:dev.azure.com\n"); + + execFileSync("openssl", [ + "req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=dev.azure.com", + "-keyout", "leaf.key", "-out", "leaf.csr", + ], { cwd: work, stdio: ["ignore", "ignore", "pipe"] }); + + execFileSync("openssl", [ + "x509", "-req", "-in", "leaf.csr", "-CA", "ca.pem", "-CAkey", "ca.key", + "-CAcreateserial", "-days", "2", "-extfile", "leaf.ext", "-out", "leaf.pem", + ], { cwd: work, stdio: ["ignore", "ignore", "pipe"] }); + + // ── the stand-in policy proxy ────────────────────────────────────────── + writeFileSync(join(work, "server.mjs"), ` +import { createServer } from "node:https"; +import { readFileSync } from "node:fs"; +const opts = { key: readFileSync("/certs/leaf.key"), cert: readFileSync("/certs/leaf.pem") }; +createServer(opts, (req, res) => { + console.log("SERVER_SAW " + req.method + " " + req.url + " host=" + req.headers.host); + const body = JSON.stringify({ marker: ${JSON.stringify(CANARY)}, url: req.url }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(body); +}).listen(443, "0.0.0.0", () => console.log("READY")); +`); + + // ── the client: knows nothing about proxies ──────────────────────────── + // Deliberately requests the real public hostname with verification ON. + writeFileSync(join(work, "client.mjs"), ` +import { get } from "node:https"; + +function viaHttpsGet(url) { + return new Promise((resolve, reject) => { + get(url, (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => resolve(body)); + }).on("error", reject); + }); +} + +const results = {}; +// Path 1: node:https — what azure-devops-node-api / typed-rest-client uses. +try { + results.httpsGet = await viaHttpsGet("https://dev.azure.com/myorg/_apis"); +} catch (e) { results.httpsGet = "ERROR: " + e.message; } + +// Path 2: global fetch (undici) — the path that ignores proxy env vars, and +// therefore the one that must be redirected below the application. +try { + const r = await fetch("https://dev.azure.com/myorg/_apis/projects"); + results.fetch = await r.text(); +} catch (e) { results.fetch = "ERROR: " + e.message; } + +// Control: an unrelated host must NOT be redirected. +try { + await viaHttpsGet("https://example.invalid/"); + results.control = "UNEXPECTEDLY RESOLVED"; +} catch (e) { results.control = "ERROR: " + e.code || e.message; } + +console.log(JSON.stringify(results, null, 2)); +`); + + // ── run it ───────────────────────────────────────────────────────────── + quietDocker(["rm", "-f", SERVER]); + quietDocker(["network", "rm", NETWORK]); + docker(["network", "create", NETWORK], { stdio: ["ignore", "pipe", "pipe"] }); + + docker([ + "run", "-d", "--name", SERVER, "--network", NETWORK, + "-v", `${work}:/certs:ro`, + IMAGE, "node", "/certs/server.mjs", + ], { stdio: ["ignore", "pipe", "pipe"] }); + + // Wait for the listener rather than sleeping blindly. + let ready = false; + for (let i = 0; i < 30; i += 1) { + if (quietDocker(["logs", SERVER]).includes("READY")) { ready = true; break; } + execFileSync(process.execPath, ["-e", "setTimeout(()=>{},500)"]); + } + if (!ready) { + console.log("server did not become ready; logs:"); + console.log(quietDocker(["logs", SERVER])); + process.exit(1); + } + + const serverIp = docker([ + "inspect", "-f", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", SERVER, + ], { stdio: ["ignore", "pipe", "pipe"] }).trim(); + + console.log(`server container IP: ${serverIp}`); + console.log(`running client with --add-host dev.azure.com:${serverIp}\n`); + + const clientOut = docker([ + "run", "--rm", "--network", NETWORK, + "--add-host", `dev.azure.com:${serverIp}`, + "-v", `${work}:/certs:ro`, + "-e", "NODE_EXTRA_CA_CERTS=/certs/ca.pem", + IMAGE, "node", "/certs/client.mjs", + ], { stdio: ["ignore", "pipe", "pipe"] }); + + console.log("=== client results ==="); + console.log(clientOut); + console.log("=== server observed ==="); + const serverLog = quietDocker(["logs", SERVER]); + for (const line of serverLog.split("\n").filter((l) => l.includes("SERVER_SAW"))) { + console.log(` ${line.trim()}`); + } + + // ── verdict ──────────────────────────────────────────────────────────── + const parsed = JSON.parse(clientOut); + const httpsOk = String(parsed.httpsGet).includes(CANARY); + const fetchOk = String(parsed.fetch).includes(CANARY); + const controlOk = String(parsed.control).startsWith("ERROR"); + + console.log("\n=== verdict ==="); + console.log(` node:https redirected, TLS verified : ${httpsOk}`); + console.log(` global fetch redirected, TLS verified: ${fetchOk}`); + console.log(` unrelated host NOT redirected : ${controlOk}`); + console.log(` OVERALL: ${httpsOk && fetchOk && controlOk ? "PASS" : "FAIL"}`); + process.exitCode = httpsOk && fetchOk && controlOk ? 0 : 1; +} finally { + cleanup(); +} diff --git a/scripts/sps-probe.mjs b/scripts/sps-probe.mjs new file mode 100644 index 000000000..78c65bccb --- /dev/null +++ b/scripts/sps-probe.mjs @@ -0,0 +1,202 @@ +/** + * Spike: can the Azure CLI be kept entirely on a policy endpoint, or does it + * always reach the deployment-level SPS host? + * + * An earlier probe saw `az` contact `app.vssps.visualstudio.com` even when + * `--organization` pointed elsewhere. That probe served a deliberately minimal + * resource-location document, so the question is whether `az` was *falling + * back* to SPS because the document did not tell it where areas live — or + * whether some calls are hardcoded to SPS regardless. + * + * This matters because the discovery document is compiler-controlled in + * production: if a faithful one keeps `az` local, SPS never needs allowing. + * + * Method: serve the same endpoint twice, once with a document that maps the + * `location` area back to our own endpoint and once without, and compare which + * hosts `az` resolves. DNS resolution is intercepted in-process so a stray SPS + * lookup is *observed* rather than silently escaping to the real internet. + * + * Usage: node scripts/sps-probe.mjs + */ +import { execFileSync, spawn } from "node:child_process"; +import dns from "node:dns"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:https"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ORG = "myorg"; + +function ensureOpenssl() { + for (const dir of ["C:\\Program Files\\Git\\usr\\bin", "C:\\Program Files\\Git\\mingw64\\bin"]) { + try { + execFileSync("openssl", ["version"], { stdio: "ignore" }); + return; + } catch { + process.env.PATH = `${dir};${process.env.PATH ?? ""}`; + } + } + execFileSync("openssl", ["version"], { stdio: "ignore" }); +} + +function resolveAz() { + const found = execFileSync("where", ["az"], { encoding: "utf8" }) + .split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + return found.find((p) => p.toLowerCase().endsWith(".cmd")) ?? found[0]; +} + +const work = mkdtempSync(join(tmpdir(), "sps-probe-")); +ensureOpenssl(); + +// A certificate valid for both the local endpoint and the SPS hostname, so we +// can serve either name from one listener. +execFileSync("openssl", [ + "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", + "-subj", "/CN=localhost", "-keyout", "k.pem", "-out", "c.pem", + "-addext", "subjectAltName=DNS:localhost,DNS:app.vssps.visualstudio.com,IP:127.0.0.1", +], { cwd: work, stdio: ["ignore", "ignore", "pipe"] }); + +const entry = (id, area, resourceName, routeTemplate) => ({ + id, area, resourceName, routeTemplate, + resourceVersion: 1, minVersion: "1.0", maxVersion: "7.2", releasedVersion: "7.1", +}); + +/** + * Two discovery documents. + * + * `faithful` additionally advertises the `location` area — the entry `az` uses + * to decide which host owns a service. `minimal` mirrors the earlier probe. + */ +const DOCUMENTS = { + minimal: () => ({ + count: 1, + value: [entry("603fe2ac-9723-48b9-88ad-09305aa6c6e1", "core", "projects", "_apis/{resource}/{*projectId}")], + }), + faithful: () => ({ + count: 3, + value: [ + entry("603fe2ac-9723-48b9-88ad-09305aa6c6e1", "core", "projects", "_apis/{resource}/{*projectId}"), + entry("e81700f7-3be2-46de-8624-2eb35882fcaa", "location", "resourceAreas", "_apis/{resource}/{areaId}"), + entry("225f7195-f9c7-4d14-ab28-a83f7ff77e1f", "git", "repositories", "{project}/_apis/git/{resource}/{repositoryId}"), + ], + }), +}; + +/** + * Resource-area lists returned from `/_apis/resourceAreas`. + * + * `sparse` is a single made-up entry (what the earlier probe served). + * `complete` advertises the real Azure DevOps area GUIDs, every one pointing + * back at our own endpoint — the best case for keeping `az` local. + */ +const AREA_LISTS = { + sparse: (port) => [{ id: "x", name: "git", locationUrl: `https://localhost:${port}/${ORG}/` }], + complete: (port) => { + const local = `https://localhost:${port}/${ORG}/`; + return [ + { id: "79134c72-4a58-4b42-976c-04e7115f32bf", name: "core", locationUrl: local }, + { id: "4e080c62-fa21-4fbc-8fef-2a10a2b38049", name: "git", locationUrl: local }, + { id: "5d6898bb-45ec-463f-95f9-54d49c71752e", name: "build", locationUrl: local }, + { id: "5264459e-e5e0-4bd8-b118-0985e68a4ec5", name: "wit", locationUrl: local }, + { id: "e81700f7-3be2-46de-8624-2eb35882fcaa", name: "location", locationUrl: local }, + ]; + }, +}; + +const SCENARIOS = [ + ["minimal document + sparse areas", DOCUMENTS.minimal, AREA_LISTS.sparse], + ["faithful document + sparse areas", DOCUMENTS.faithful, AREA_LISTS.sparse], + ["faithful document + complete areas", DOCUMENTS.faithful, AREA_LISTS.complete], +]; + +async function runScenario(name, buildDocument, buildAreas) { + const seen = []; + const server = createServer( + { key: readFileSync(join(work, "k.pem")), cert: readFileSync(join(work, "c.pem")) }, + (req, res) => { + seen.push(`${req.method} ${req.headers.host}${req.url}`); + const send = (o) => { + const b = JSON.stringify(o); + res.writeHead(200, { "content-type": "application/json", "content-length": Buffer.byteLength(b) }); + res.end(b); + }; + if (req.method === "OPTIONS") return send(buildDocument()); + const path = req.url.split("?")[0].toLowerCase(); + // The location service: every area resolves back to *this* endpoint, + // which is what should keep az from going to the real SPS. + if (path.includes("/_apis/resourceareas")) { + const port = server.address().port; + const areas = buildAreas(port); + return send({ count: areas.length, value: areas }); + } + return send({ count: 1, value: [{ id: "1", name: "Widgets", state: "wellFormed" }] }); + }); + + const port = await new Promise((r) => server.listen(0, "127.0.0.1", () => r(server.address().port))); + + // Point the SPS hostname at our own listener so a fallback is observable + // rather than escaping to the real service. + const originalLookup = dns.lookup; + const spsHits = []; + + const az = resolveAz(); + const result = await new Promise((resolve) => { + const child = spawn(`"${az}"`, [ + "devops", "project", "list", + "--organization", `https://localhost:${port}/${ORG}`, + "-o", "json", "--detect", "false", "--debug", + ], { + shell: true, + env: { + ...process.env, + REQUESTS_CA_BUNDLE: join(work, "c.pem"), + AZURE_DEVOPS_EXT_PAT: "dummy-pat-for-probe", + AZURE_CORE_COLLECT_TELEMETRY: "no", + }, + }); + let out = "", err = ""; + child.stdout.on("data", (c) => (out += c)); + child.stderr.on("data", (c) => (err += c)); + child.on("close", (code) => resolve({ code, out, err })); + }); + + dns.lookup = originalLookup; + await new Promise((r) => server.close(r)); + + // Which hosts did az actually try to reach? + const hosts = new Set(); + const spsRequests = []; + for (const line of result.err.split("\n")) { + const m = line.match(/devops_sdk\.client:\s+(GET|OPTIONS|POST)\s+https:\/\/([^/\s]+)(\S*)/); + if (m) { + hosts.add(m[2]); + if (m[2].includes("vssps") || m[2].includes("visualstudio.com")) { + spsRequests.push(`${m[1]} ${m[3]}`); + } + } + } + + return { name, port, seen, hosts: [...hosts], code: result.code, spsRequests, stderr: result.err }; +} + +console.log("Does a faithful resource-location document keep `az` off SPS?\n"); + +for (const [name, buildDocument, buildAreas] of SCENARIOS) { + const r = await runScenario(name, buildDocument, buildAreas); + const wentToSps = r.hosts.some((h) => h.includes("vssps") || h.includes("visualstudio.com")); + console.log(`── ${name} ──`); + console.log(` az exit: ${r.code}`); + console.log(` hosts az addressed: ${r.hosts.join(", ") || "(none parsed)"}`); + console.log(` requests our endpoint served:`); + for (const s of r.seen) console.log(` ${s}`); + console.log(` REACHED SPS: ${wentToSps ? "YES" : "no"}`); + if (r.spsRequests.length > 0) { + console.log(` what it asked SPS for:`); + for (const s of r.spsRequests) console.log(` ${s}`); + } + const notReg = r.stderr.split("\n").filter((l) => /not registered|resource location/i.test(l)).slice(0, 2); + for (const l of notReg) console.log(` note: ${l.trim().slice(0, 160)}`); + console.log(""); +} + +rmSync(work, { recursive: true, force: true }); From 2045f05866dcc300d57bae226fa94998990b3b73 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sat, 1 Aug 2026 22:57:06 +0100 Subject: [PATCH 07/42] fix(ado-proxy): rewrite resource-area locationUrl instead of filtering it The /_apis/resourceAreas response is what tells az where each Azure DevOps service lives, so it decides whether az stays on the policy endpoint. The filter-resource-areas policy dropped entries whose locationUrl was not already a protected host - which empties the list in the normal case and sends az straight to deployment-level SPS, the opposite of the intent. Rewrite instead: replace each URL's scheme and host with the origin the client is already talking to, preserve the path, and drop only entries that cannot be rewritten at all. The origin differs between the intercepted MCP path and the az broker path, so it is passed in rather than assumed. Evidence, through the real bundle rather than a unit test: the fake upstream now deliberately advertises vsrm.dev.azure.com, so a working rewrite is the only thing that can keep az on the policed origin. Both z devops project list and z repos show return exit 0 with correct JSON, every request matches a catalogued operation, SPS is never contacted, the sentinel PAT never reaches the upstream, and the injected bearer does. That is the first time stock az has completed end to end against the proxy with no real credential - one of the production gates in the design doc. 892 TS tests green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 7 +- .../ado-script/src/ado-proxy/response.test.ts | 62 +++++++++++++++--- scripts/ado-script/src/ado-proxy/response.ts | 64 ++++++++++++++++--- scripts/ado-script/src/ado-proxy/server.ts | 9 ++- scripts/az-probe.mjs | 38 +++++++++-- 5 files changed, 155 insertions(+), 25 deletions(-) diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index 798956a02..d0a5f3df2 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -326,6 +326,7 @@ re-derived. | **`--add-host` redirects a container to the proxy, TLS verified** | A `node:20-slim` container given `--add-host dev.azure.com:` and `NODE_EXTRA_CA_CERTS` reached the stand-in proxy over **both** `node:https` *and* global `fetch`, with `rejectUnauthorized` left on. Server observed `Host: dev.azure.com`, so the client genuinely believed it was talking to Azure DevOps | | **The redirect is narrow** | In the same run an unrelated host failed `ENOTFOUND` — only the named host is affected | | **SPS is avoidable, and `az` completes entirely against the policy endpoint** | Three scenarios (`scripts/sps-probe.mjs`): a *minimal* discovery document fails (`location` area not registered); *faithful* document + a sparse area list falls back to `app.vssps.visualstudio.com`; *faithful* document + a **complete** area list — real area GUIDs, every `locationUrl` pointing back at the endpoint — completed with **exit 0** and never contacted SPS | +| **Stock `az` works end to end through the real bundle with no real credential** | With the rewrite implemented, `az devops project list` and `az repos show` both returned **exit 0** and correct JSON. The fake upstream deliberately advertised `vsrm.dev.azure.com`; `az` stayed on the policed origin throughout, and SPS was never contacted. Every request was matched to a catalogued operation (`discovery.host-options`, `discovery.resource-areas`, `core.project-validation-probe`, `repos.repository-get`); the sentinel PAT never reached the upstream and the injected bearer did | | Upstream verification is real | The engine refused a self-signed upstream with `unable to verify the first certificate` | | Denials surface usefully to clients | `az` printed the engine's `WrappedException` message verbatim | @@ -357,8 +358,10 @@ determines whether it stays on the policy endpoint: The engine must therefore **rewrite** `locationUrl` to itself rather than merely filtering the list. A filter that drops entries not matching a protected host would empty the list and reintroduce the SPS fallback — the opposite of -the intent. This supersedes the drop-only `filter-resource-areas` behaviour -currently implemented in `response.ts`. +the intent. Implemented in `response.ts` as the `filter-resource-areas` policy: +each URL's scheme and host are replaced with the origin the client is already +using, the path is preserved, and only entries that cannot be rewritten at all +are dropped. ## Open questions diff --git a/scripts/ado-script/src/ado-proxy/response.test.ts b/scripts/ado-script/src/ado-proxy/response.test.ts index 567378e2b..5248e5822 100644 --- a/scripts/ado-script/src/ado-proxy/response.test.ts +++ b/scripts/ado-script/src/ado-proxy/response.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { CATALOG_SCHEMA_VERSION, OPERATIONS } from "./catalog.js"; import type { ProxyPolicy } from "./config.js"; -import { filterResponse, isProtectedLocation } from "./response.js"; +import { filterResponse, isProtectedLocation, rewriteLocationUrl } from "./response.js"; import type { Operation } from "../shared/ado-proxy-catalog.types.gen.js"; const POLICY: ProxyPolicy = { @@ -24,11 +24,14 @@ function operation(id: string): Operation { return found; } +const SELF_ORIGIN = "https://dev.azure.com"; + function apply(id: string, document: unknown): ReturnType { return filterResponse( operation(id), POLICY, Buffer.from(JSON.stringify(document), "utf8"), + SELF_ORIGIN, ); } @@ -41,7 +44,7 @@ function forwarded(outcome: ReturnType): unknown { describe("filterResponse — pass-through", () => { it("forwards a plain JSON operation byte-for-byte", () => { const body = Buffer.from('{"id":"not even valid for this shape"}', "utf8"); - const outcome = filterResponse(operation("core.project-get"), POLICY, body); + const outcome = filterResponse(operation("core.project-get"), POLICY, body, SELF_ORIGIN); expect(outcome.kind).toBe("forward"); if (outcome.kind === "forward") expect(outcome.body.equals(body)).toBe(true); }); @@ -75,26 +78,69 @@ describe("filterResponse — project list", () => { operation("core.project-validation-probe"), POLICY, Buffer.from("sign in", "utf8"), + SELF_ORIGIN, ); expect(outcome.kind).toBe("deny"); }); }); describe("filterResponse — resource areas", () => { - it("drops areas that point outside the protected set", () => { - // A retained entry would send the client's next call to a host this proxy - // does not police. + it("rewrites every locationUrl back to the proxy", () => { + // This response is what tells `az` where each service lives. Measured: + // return the real areas pointing at the policy endpoint and `az` completes + // without ever contacting deployment-level SPS. const outcome = apply("discovery.resource-areas", { count: 2, value: [ - { id: "a", locationUrl: "https://dev.azure.com/contoso/" }, - { id: "b", locationUrl: "https://vsrm.dev.azure.com/contoso/" }, + { id: "a", name: "core", locationUrl: "https://dev.azure.com/contoso/" }, + { id: "b", name: "git", locationUrl: "https://vsrm.dev.azure.com/contoso/" }, ], }); expect(forwarded(outcome)).toEqual({ + count: 2, + value: [ + { id: "a", name: "core", locationUrl: "https://dev.azure.com/contoso/" }, + // Rewritten off the unpoliced host rather than dropped — dropping it + // would shrink the list and send `az` to the SPS fallback. + { id: "b", name: "git", locationUrl: "https://dev.azure.com/contoso/" }, + ], + }); + }); + + it("preserves the path while replacing the host", () => { + const outcome = apply("discovery.resource-areas", { count: 1, - value: [{ id: "a", locationUrl: "https://dev.azure.com/contoso/" }], + value: [{ id: "a", locationUrl: "https://other.example/contoso/sub/path" }], }); + const body = forwarded(outcome) as { value: { locationUrl: string }[] }; + expect(body.value[0]?.locationUrl).toBe("https://dev.azure.com/contoso/sub/path"); + }); + + it("drops only entries whose locationUrl cannot be rewritten", () => { + const outcome = apply("discovery.resource-areas", { + count: 3, + value: [ + { id: "a", locationUrl: "https://dev.azure.com/contoso/" }, + { id: "b", locationUrl: "not a url" }, + { id: "c" }, + ], + }); + const body = forwarded(outcome) as { count: number }; + expect(body.count).toBe(1); + }); +}); + +describe("rewriteLocationUrl", () => { + it("replaces scheme and host, keeps the path", () => { + expect(rewriteLocationUrl("https://vsrm.dev.azure.com/org/", "https://proxy:8443")).toBe( + "https://proxy:8443/org/", + ); + }); + + it("returns undefined for unusable input", () => { + expect(rewriteLocationUrl("nonsense", "https://proxy")).toBeUndefined(); + expect(rewriteLocationUrl(undefined, "https://proxy")).toBeUndefined(); + expect(rewriteLocationUrl(42, "https://proxy")).toBeUndefined(); }); }); diff --git a/scripts/ado-script/src/ado-proxy/response.ts b/scripts/ado-script/src/ado-proxy/response.ts index 62a2f4ba9..a258270ee 100644 --- a/scripts/ado-script/src/ado-proxy/response.ts +++ b/scripts/ado-script/src/ado-proxy/response.ts @@ -71,6 +71,14 @@ export function filterResponse( operation: Operation, policy: ProxyPolicy, body: Buffer, + /** + * Origin the client used to reach this proxy, e.g. `https://dev.azure.com`. + * + * Only the resource-area rewrite needs it: service locations must point back + * at whatever origin the client is already talking to, which differs between + * the intercepted MCP path and the `az` broker path. + */ + selfOrigin: string, ): FilterOutcome { const responsePolicy: ResponsePolicy = operation.response; if (responsePolicy === "json") return forward(body); @@ -106,14 +114,28 @@ export function filterResponse( case "filter-resource-areas": { const values = listValues(record); if (values === undefined) return denyBody("resource area list had no value array"); - // A resource area whose locationUrl points outside the protected set - // would send the client — and therefore the next request — to a host this - // proxy does not police. Drop those rather than rewriting them. - const kept = values.filter((entry) => { - const area = asRecord(entry); - if (area === undefined) return false; - return isProtectedLocation(area.locationUrl); - }); + // Rewrite, do not merely filter. + // + // `az` resolves service locations from this response, and it is the + // single point that decides whether it stays on the policy endpoint. + // Measured (scripts/sps-probe.mjs): omit the `location` area and `az` + // fails outright; return an incomplete list and it falls back to + // deployment-level SPS; return the real areas pointing back at the + // policy endpoint and it completes without ever contacting SPS. + // + // Dropping entries whose `locationUrl` is not already a protected host + // would empty the list and reintroduce exactly that fallback — the + // opposite of the intent. So each URL is rewritten to the origin the + // client is already talking to, and only entries that cannot be + // rewritten are dropped. + const kept: unknown[] = []; + for (const value of values) { + const area = asRecord(value); + if (area === undefined) continue; + const rewritten = rewriteLocationUrl(area.locationUrl, selfOrigin); + if (rewritten === undefined) continue; + kept.push({ ...area, locationUrl: rewritten }); + } return reserialize({ count: kept.length, value: kept }); } @@ -161,6 +183,32 @@ export function filterResponse( } } +/** + * Point a service `locationUrl` back at the proxy, preserving its path. + * + * Azure DevOps returns absolute URLs like + * `https://dev.azure.com/contoso/` — the host must become the origin the client + * is already using, or the client's next request leaves the policed path. + * Returns `undefined` for anything unparseable, which the caller drops. + */ +export function rewriteLocationUrl( + locationUrl: unknown, + selfOrigin: string, +): string | undefined { + if (typeof locationUrl !== "string") return undefined; + let parsed: URL; + let origin: URL; + try { + parsed = new URL(locationUrl); + origin = new URL(selfOrigin); + } catch { + return undefined; + } + parsed.protocol = origin.protocol; + parsed.host = origin.host; + return parsed.toString(); +} + /** True when a discovery `locationUrl` resolves to a protected host. */ export function isProtectedLocation(locationUrl: unknown): boolean { if (typeof locationUrl !== "string") return false; diff --git a/scripts/ado-script/src/ado-proxy/server.ts b/scripts/ado-script/src/ado-proxy/server.ts index 12009976e..782a80fbd 100644 --- a/scripts/ado-script/src/ado-proxy/server.ts +++ b/scripts/ado-script/src/ado-proxy/server.ts @@ -351,7 +351,14 @@ async function handleProtected( const body = await readBounded(upstreamResponse, decision.operation.max_response_bytes); const status = upstreamResponse.statusCode ?? 502; - const outcome = filterResponse(decision.operation, deps.config.policy, body); + // Service locations must point back at the origin the client is already + // using, which is the intercepted hostname — not the upstream's own. + const outcome = filterResponse( + decision.operation, + deps.config.policy, + body, + `https://${host}`, + ); if (outcome.kind === "deny") { deps.log.write({ diff --git a/scripts/az-probe.mjs b/scripts/az-probe.mjs index a679591c4..85c365bb8 100644 --- a/scripts/az-probe.mjs +++ b/scripts/az-probe.mjs @@ -122,12 +122,33 @@ function resourceLocations(host) { const value = [ entry("603fe2ac-9723-48b9-88ad-09305aa6c6e1", "core", "projects", "_apis/{resource}/{*projectId}"), + // The `location` area is required: without it `az` fails outright with + // "API resource location e81700f7-… is not registered". + entry("e81700f7-3be2-46de-8624-2eb35882fcaa", "location", "resourceAreas", "_apis/{resource}/{areaId}"), entry("225f7195-f9c7-4d14-ab28-a83f7ff77e1f", "git", "repositories", "{project}/_apis/git/{resource}/{repositoryId}"), entry("dbeaf647-6167-421a-bda9-c9327b25e2e6", "build", "builds", "{project}/_apis/build/{resource}/{buildId}"), ]; return { count: value.length, value }; } +/** + * The upstream's own resource-area list. + * + * Deliberately points at hosts *other* than the intercepted one, so the run + * proves the proxy rewrites them rather than the fake upstream having been + * pre-cooked to look correct. + */ +function upstreamResourceAreas() { + const upstream = `https://vsrm.dev.azure.com/${ORG}/`; + return [ + { id: "79134c72-4a58-4b42-976c-04e7115f32bf", name: "core", locationUrl: upstream }, + { id: "4e080c62-fa21-4fbc-8fef-2a10a2b38049", name: "git", locationUrl: upstream }, + { id: "5d6898bb-45ec-463f-95f9-54d49c71752e", name: "build", locationUrl: upstream }, + { id: "5264459e-e5e0-4bd8-b118-0985e68a4ec5", name: "wit", locationUrl: upstream }, + { id: "e81700f7-3be2-46de-8624-2eb35882fcaa", name: "location", locationUrl: upstream }, + ]; +} + /** Realistic Azure DevOps responses for the routes az actually calls. */ function respond(url, method, host, response) { const path = url.split("?")[0].toLowerCase(); @@ -139,13 +160,14 @@ function respond(url, method, host, response) { if (method === "OPTIONS") return json(resourceLocations(host)); - // The location service: every area resolves to the organization host, which - // is what sends az back to dev.azure.com for real data. + // The location service. The upstream advertises non-intercepted hosts; the + // proxy's rewrite is what must bring them back to the policed origin. if (path.includes("/_apis/resourceareas")) { if (path.endsWith("/resourceareas")) { - return json({ count: 1, value: [{ id: "79134c72-4a58-4b42-976c-04e7115f32bf", name: "git", locationUrl: `https://dev.azure.com/${ORG}/` }] }); + const areas = upstreamResourceAreas(); + return json({ count: areas.length, value: areas }); } - return json({ id: path.split("/").pop(), name: "git", locationUrl: `https://dev.azure.com/${ORG}/` }); + return json({ id: path.split("/").pop(), name: "git", locationUrl: `https://vsrm.dev.azure.com/${ORG}/` }); } if (path.endsWith("/_apis/projects")) { @@ -181,13 +203,15 @@ async function start() { const adoCert = mintCa(adoDir, ["dev.azure.com", "app.vssps.visualstudio.com"]); const adoApp = createHttpServer((request, response) => { + const host = String(request.headers.host ?? "").split(":")[0].toLowerCase(); upstreamCalls.push({ method: request.method, url: request.url, + host, authorization: request.headers.authorization ?? "(none)", accept: request.headers.accept ?? "(none)", }); - respond(request.url, request.method, response); + respond(request.url, request.method, host, response); }); const adoTls = createTlsServer({ key: adoCert.key, cert: adoCert.cert }); adoTls.on("secureConnection", (socket) => adoApp.emit("connection", socket)); @@ -353,7 +377,9 @@ for (const [label, args] of scenarios) { const before = upstreamCalls.length; const result = await runAz(args, azEnv); console.log(` exit code: ${result.code}`); - writeFileSync(join(process.cwd(), `az-${label.split(" ")[0]}-stderr.log`), result.stderr ?? ""); + // Full stderr goes to the OS temp dir, not the repo — these are debug + // artifacts of a probe run, not source. + writeFileSync(join(tmpdir(), `az-probe-${label.split(" ")[0]}-stderr.log`), result.stderr ?? ""); if (!result.ok) console.log(` stderr: ${(result.stderr || "(empty)").split("\n").filter((l) => l.includes("ERROR")).slice(0, 4).join("\n ")}`); else console.log(` stdout: ${result.stdout.slice(0, 300).replace(/\s+/g, " ")}`); console.log(` upstream requests: ${upstreamCalls.length - before}`); From f78b2b3e717e1e30d90951a5d6020839b4e0fada Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 2 Aug 2026 07:58:30 +0100 Subject: [PATCH 08/42] refactor(ado-proxy): read interception certificates from stdin The engine minted its own CA with openssl, which forced it onto the full node:20 image (node:20-slim has no openssl) and put private keys on a filesystem. Neither was necessary. The real constraint is only that the private key must never be agent-readable. The engine starts before AWF, so at generation time no agent exists at all - and passing the material on stdin means it touches no filesystem, so there is no window to get wrong and nothing to delete afterwards. Generation moves to a host pipeline step. That adds no dependency: every compiled pipeline already requires host openssl, since prepare_mcpg_config_step mints the MCPG API key with openssl rand on every run. A helper container would have been a second image to pull and mirror for air-gapped customers, for no gain. Because the protected host set is compiler-known, the leaves are generated alongside the CA and arrive in the same stream, so the engine never needs to issue a certificate - which suits Node, as it can parse X.509 but not issue it. The engine therefore needs no openssl and runs on node:20-slim, already the Azure DevOps MCP image, so nothing new enters the supply chain. ca.ts inverts from minting to parsing, keeping the CaMaterials shape so server.ts and the SNI callback are untouched. The stream is section-marked rather than bare concatenated PEM so each leaf stays bound to its hostname; relying on order would be a silent correctness trap if the generator changed. Fail-closed throughout, verified in a container against the real bundle: an empty stream exits 1 with "no certificate material on stdin"; a CA with no leaves exits 1 with "certificate stream carried no host leaves". A half-formed leaf is rejected rather than served, since TLS would otherwise fail at handshake time with nothing pointing at the material as the cause. publishCaCertificate now refuses anything containing a private key, because that path is mounted into the MCP container. Also proven: host openssl generates -> real bundle on node:20-slim -> engine listening. 905 TS tests green, 13 of them new. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 69 +++++ scripts/ado-script/src/ado-proxy/ca.test.ts | 146 +++++++++++ scripts/ado-script/src/ado-proxy/ca.ts | 245 ++++++++---------- scripts/ado-script/src/ado-proxy/index.ts | 38 +-- .../src/ado-proxy/proxy.e2e.test.ts | 55 +++- 5 files changed, 383 insertions(+), 170 deletions(-) create mode 100644 scripts/ado-script/src/ado-proxy/ca.test.ts diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index d0a5f3df2..97448aa23 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -168,6 +168,75 @@ rejected on evidence: Per-client scoping avoids all of this and yields a smaller blast radius. +### Where the CA is minted + +The constraint is narrow: **the CA private key must never be readable by the +agent.** It does not follow that the key must be minted inside the engine's +container — an earlier draft claimed that, and it was wrong. + +Two facts make a simpler arrangement safe. The engine starts *before* the AWF +invocation, so during CA setup no agent exists to read anything. And the +material can be passed on **stdin**, so it never touches a filesystem at all — +not the runner's, not the container's. + +Because the protected host set is compiler-known, the **leaves are generated +alongside the CA**, and the whole lot arrives as one PEM stream from a host +pipeline step: + +```sh +# host step: mints CA + one leaf per protected host, straight to stdout +generate_ca_material \ + | docker run -i --name ado-proxy … node:20-slim ado-proxy.js +``` + +Generation runs directly on the runner with `openssl`, not in a helper +container. Every compiled pipeline **already** depends on host `openssl` — +`prepare_mcpg_config_step` mints the MCPG API key with `openssl rand` on every +run — so this adds no new dependency, no second image to pull, and nothing +further for `supply-chain:` to mirror. A helper container would have been pure +overhead. + +Verified end to end: CA and all three leaves (`dev.azure.com`, +`app.vssps.visualstudio.com`, and the engine's own broker hostname) reach the +container, and a client verifies the served identity **as `dev.azure.com` +against the piped CA** (`authorized: true`). Piping configuration into a +container this way is the pattern MCPG already uses (`echo "$MCPG_CONFIG" | +docker run -i …`). + +`openssl` being absent is a hard failure, not a degradation: the step must exit +non-zero rather than continue without an interception identity. + + + +Why this matters for the agent: AWF's chroot makes the agent's root the host's +`/host` bind mount, so the agent's `/tmp` **is** the runner's `/tmp` — which is +how AWF installs its own `gh` wrapper (`cp … /host/tmp/awf-lib/gh` appears +inside the chroot as `/tmp/awf-lib/gh`). A key written to a host path would +therefore be agent-readable. Keeping it on stdin sidesteps that entirely, rather +than relying on deleting it in time. + +Only the **public** certificate is written to a host path, so it can be mounted +into the MCP container for `NODE_EXTRA_CA_CERTS`. + +This also frees the base image. `ca.ts` shells out to `openssl` because Node can +parse X.509 but cannot issue it, and adding a certificate library would +reintroduce the native dependency this runtime exists to avoid. Measured: + +| Image | `openssl` | +|---|---| +| `node:20-slim`, `node:20-bookworm-slim` | **absent** | +| `node:20` | present (3.0.19) | + +With both CA and leaves generated on the host ahead of the engine, the engine +needs no `openssl` at all and runs on `node:20-slim`. A restart is fail-closed: +the engine holds the material in memory only, so a dead container ends the run +rather than silently serving a new CA the MCP does not trust. + +`ca.ts` therefore changes from *minting* to *parsing* — it keeps the same +`CaMaterials` shape so nothing downstream moves. + + + ### Upstream leg The engine verifies the real Azure DevOps certificate normally; diff --git a/scripts/ado-script/src/ado-proxy/ca.test.ts b/scripts/ado-script/src/ado-proxy/ca.test.ts new file mode 100644 index 000000000..9eab69020 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/ca.test.ts @@ -0,0 +1,146 @@ +/** + * Parser tests for the piped interception material. + * + * These use synthetic PEM blocks rather than real `openssl` output: the parser + * cares about *structure*, and shape-only fixtures keep the suite fast and + * free of a toolchain dependency. Real material is exercised end to end in + * `proxy.e2e.test.ts`. + */ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { CaError, parseCaMaterials, publishCaCertificate, readCaMaterials } from "./ca.js"; + +const KEY = "-----BEGIN PRIVATE KEY-----\nMIIfake\n-----END PRIVATE KEY-----\n"; +const CERT = "-----BEGIN CERTIFICATE-----\nMIIfake\n-----END CERTIFICATE-----\n"; + +function stream(...sections: string[]): string { + return sections.join(""); +} + +const CA_SECTION = `### CA\n${CERT}`; +const host = (name: string): string => `### HOST ${name}\n${KEY}${CERT}`; + +describe("parseCaMaterials", () => { + it("parses a CA and its leaves", () => { + const materials = parseCaMaterials( + stream(CA_SECTION, host("dev.azure.com"), host("app.vssps.visualstudio.com")), + ); + expect(materials.caCertPem).toContain("BEGIN CERTIFICATE"); + expect([...materials.leaves.keys()].sort()).toEqual([ + "app.vssps.visualstudio.com", + "dev.azure.com", + ]); + expect(materials.leaves.get("dev.azure.com")?.key).toContain("BEGIN PRIVATE KEY"); + }); + + it("lowercases hostnames so SNI lookup cannot miss on case", () => { + const materials = parseCaMaterials(stream(CA_SECTION, host("DEV.Azure.COM"))); + expect(materials.leaves.has("dev.azure.com")).toBe(true); + }); + + it("rejects an empty stream", () => { + // The likeliest real failure: the container was started without the pipe. + expect(() => parseCaMaterials("")).toThrow(/no certificate material on stdin/); + expect(() => parseCaMaterials(" \n ")).toThrow(CaError); + }); + + it("rejects a stream with no CA", () => { + expect(() => parseCaMaterials(stream(host("dev.azure.com")))).toThrow(/no CA section/); + }); + + it("rejects a stream with no leaves", () => { + // Without a leaf there is nothing to serve, so every intercepted request + // would fail at handshake time with no clue as to why. + expect(() => parseCaMaterials(CA_SECTION)).toThrow(/no host leaves/); + }); + + it("rejects a half-formed leaf rather than serving it", () => { + expect(() => + parseCaMaterials(stream(CA_SECTION, `### HOST dev.azure.com\n${CERT}`)), + ).toThrow(/missing its key/); + expect(() => + parseCaMaterials(stream(CA_SECTION, `### HOST dev.azure.com\n${KEY}`)), + ).toThrow(/missing its certificate/); + }); + + it("rejects a CA section carrying no certificate", () => { + expect(() => parseCaMaterials(stream("### CA\n(nothing)\n", host("h")))).toThrow( + /CA section carried no certificate/, + ); + }); + + it("rejects a host section with no hostname", () => { + expect(() => parseCaMaterials(stream(CA_SECTION, `### HOST \n${KEY}${CERT}`))).toThrow( + /no hostname/, + ); + }); + + it("ignores unrecognised sections rather than failing", () => { + // Forward compatibility: a generator adding a section this build does not + // know about must not take the proxy down. + const materials = parseCaMaterials( + stream(CA_SECTION, "### FUTURE thing\nwhatever\n", host("dev.azure.com")), + ); + expect(materials.leaves.size).toBe(1); + }); +}); + +describe("publishCaCertificate", () => { + let directory: string; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "ado-proxy-ca-pub-")); + }); + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }); + }); + + it("writes the public certificate", () => { + const path = join(directory, "ca.pem"); + publishCaCertificate(path, CERT); + expect(readCaMaterials).toBeTypeOf("function"); + expect(() => publishCaCertificate(path, CERT)).not.toThrow(); + }); + + it("refuses to publish anything containing a private key", () => { + // This path is mounted into the MCP container; a key reaching it would + // hand out the ability to impersonate any protected host. + expect(() => publishCaCertificate(join(directory, "ca.pem"), `${CERT}${KEY}`)).toThrow( + /private key/, + ); + }); +}); + +describe("readCaMaterials", () => { + let directory: string; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "ado-proxy-ca-read-")); + }); + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }); + }); + + it("reads and parses from a descriptor", () => { + const path = join(directory, "material.pem"); + writeFileSync(path, stream(CA_SECTION, host("dev.azure.com"))); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { openSync, closeSync } = require("node:fs") as typeof import("node:fs"); + const fd = openSync(path, "r"); + try { + expect(readCaMaterials(fd).leaves.has("dev.azure.com")).toBe(true); + } finally { + closeSync(fd); + } + }); + + it("reports an unreadable descriptor as a CaError", () => { + expect(() => readCaMaterials(9999)).toThrow(CaError); + }); +}); diff --git a/scripts/ado-script/src/ado-proxy/ca.ts b/scripts/ado-script/src/ado-proxy/ca.ts index 9662f7d75..08ad0dffa 100644 --- a/scripts/ado-script/src/ado-proxy/ca.ts +++ b/scripts/ado-script/src/ado-proxy/ca.ts @@ -1,177 +1,148 @@ /** - * Ephemeral interception CA and per-host leaf certificates. + * Interception certificate material, supplied on stdin. * - * Node cannot *create* X.509 certificates: `node:crypto` can generate key pairs - * and parse certificates, but has no issuance API. The options are a native - * crypto dependency (which is what pushed this runtime off Rust in the first - * place) or the `openssl` binary that is already present in the AWF agent image - * and already used by AWF's own ssl-bump setup. This module takes the second - * path, so the bundle keeps zero runtime dependencies. + * The engine does **not** mint its own certificates. A host pipeline step runs + * `openssl` — already an unconditional dependency of every compiled pipeline, + * which mints the MCPG API key with `openssl rand` — and pipes the CA plus one + * leaf per protected host straight into `docker run -i`. Two consequences: * - * Key custody: every private key is written under a caller-supplied directory - * that must be container tmpfs. Only the CA's *public* certificate is ever - * copied out, into the pre-created file AWF installs into the agent's trust - * stores. + * - **The private keys touch no filesystem.** Not the runner's, not the + * container's. AWF's chroot makes the agent's root the host's `/host` bind + * mount, so the agent's `/tmp` *is* the runner's `/tmp`; a key written to a + * host path would be agent-readable. Keeping it on stdin sidesteps that + * rather than relying on deleting it in time. There is no exposure window + * to get wrong either, because the engine starts before AWF — at generation + * time no agent exists at all. + * - **The engine needs no `openssl`,** so it runs on `node:20-slim` (which + * has none) rather than the full `node:20`. That is already the image the + * Azure DevOps MCP uses, so it adds nothing to mirror. + * + * The protected host set is compiler-known, so every leaf can be generated + * ahead of time. Nothing here has to *issue* a certificate — fortunate, since + * Node can parse X.509 but not issue it. */ -import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { readFileSync, writeFileSync } from "node:fs"; export class CaError extends Error {} -/** A minted leaf certificate for one protected host. */ +/** A leaf certificate and its key, for one protected host. */ export interface Leaf { readonly key: string; readonly cert: string; } -/** Materials produced by {@link mintCa}. */ +/** Parsed interception material. */ export interface CaMaterials { /** PEM of the CA certificate. Safe to publish. */ readonly caCertPem: string; - /** Leaf key/cert per protected host, keyed by lowercase hostname. */ + /** Leaf key/cert per host, keyed by lowercase hostname. */ readonly leaves: ReadonlyMap; } -const DAYS = "2"; -const SUBJECT = "/CN=ado-proxy ephemeral interception CA"; +/** + * Section markers in the piped stream. + * + * A marker format rather than bare PEM concatenation, because each leaf's key + * and certificate must stay associated with *its* hostname — relying on order + * alone would be a silent correctness trap if the generator ever changed. + */ +const CA_MARKER = "### CA"; +const HOST_MARKER = "### HOST "; -function openssl(args: readonly string[], cwd: string): void { - try { - execFileSync("openssl", args as string[], { - cwd, - stdio: ["ignore", "ignore", "pipe"], - timeout: 60_000, - }); - } catch (error) { - const stderr = (error as { stderr?: Buffer }).stderr?.toString().trim(); - throw new CaError( - `openssl ${args[0]} failed${stderr === undefined || stderr === "" ? "" : `: ${stderr}`}`, - ); - } -} +const PRIVATE_KEY = + /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC )?PRIVATE KEY-----/; +const CERTIFICATE = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/; /** - * Generate a fresh CA and one leaf per protected host. + * Parse the certificate stream. * - * All hosts are known at startup — the protected set is compiler-pinned and - * tiny — so leaves are minted eagerly. That keeps `openssl` off the request - * path entirely and means a broken toolchain fails at startup rather than on - * the first intercepted connection. + * Fails closed on anything incomplete: a missing CA, a host section without + * both a key and a certificate, or a stream carrying no hosts at all. Each + * would otherwise surface as an opaque TLS handshake failure on the first + * intercepted request, long after the cause. */ -export function mintCa( - directory: string, - hosts: readonly string[], -): CaMaterials { - mkdirSync(directory, { recursive: true, mode: 0o700 }); - - openssl( - [ - "req", - "-x509", - "-newkey", - "rsa:2048", - "-nodes", - "-days", - DAYS, - "-subj", - SUBJECT, - "-keyout", - "ca.key", - "-out", - "ca.pem", - "-addext", - "basicConstraints=critical,CA:TRUE,pathlen:0", - "-addext", - "keyUsage=critical,keyCertSign,cRLSign", - ], - directory, - ); +export function parseCaMaterials(raw: string): CaMaterials { + if (raw.trim() === "") { + throw new CaError( + "no certificate material on stdin; the host generation step must pipe the " + + "CA and leaves into this container", + ); + } + const sections = raw.split("### ").slice(1); + let caCertPem: string | undefined; const leaves = new Map(); - for (const rawHost of hosts) { - const host = rawHost.toLowerCase(); - if (leaves.has(host)) continue; - if (!/^[a-z0-9.-]+$/.test(host)) { - // The protected set is compiler-owned, but this string ends up in an - // openssl config file; refuse anything that could break out of it. - throw new CaError(`refusing to mint a certificate for host ${rawHost}`); + + for (const section of sections) { + const body = `### ${section}`; + + if (body.startsWith(CA_MARKER)) { + const cert = CERTIFICATE.exec(body)?.[0]; + if (cert === undefined) throw new CaError("CA section carried no certificate"); + caCertPem = cert; + continue; } - const keyFile = `${host}.key`; - const csrFile = `${host}.csr`; - const certFile = `${host}.pem`; - const extFile = `${host}.ext`; - - writeFileSync( - join(directory, extFile), - [ - "basicConstraints=CA:FALSE", - "keyUsage=critical,digitalSignature,keyEncipherment", - "extendedKeyUsage=serverAuth", - `subjectAltName=DNS:${host}`, - "", - ].join("\n"), - { mode: 0o600 }, - ); + if (!body.startsWith(HOST_MARKER)) continue; - openssl( - [ - "req", - "-new", - "-newkey", - "rsa:2048", - "-nodes", - "-subj", - `/CN=${host}`, - "-keyout", - keyFile, - "-out", - csrFile, - ], - directory, - ); + const newline = body.indexOf("\n"); + const host = body + .slice(HOST_MARKER.length, newline === -1 ? undefined : newline) + .trim() + .toLowerCase(); + if (host === "") throw new CaError("host section carried no hostname"); - openssl( - [ - "x509", - "-req", - "-in", - csrFile, - "-CA", - "ca.pem", - "-CAkey", - "ca.key", - "-CAcreateserial", - "-days", - DAYS, - "-extfile", - extFile, - "-out", - certFile, - ], - directory, - ); + const key = PRIVATE_KEY.exec(body)?.[0]; + const cert = CERTIFICATE.exec(body)?.[0]; + if (key === undefined || cert === undefined) { + // Half a leaf is worse than none: TLS would fail at handshake time with + // nothing to indicate the *material* was the problem. + throw new CaError( + `leaf for ${host} is missing its ${key === undefined ? "key" : "certificate"}`, + ); + } + leaves.set(host, { key, cert }); + } - leaves.set(host, { - key: readFileSync(join(directory, keyFile), "utf8"), - cert: readFileSync(join(directory, certFile), "utf8"), - }); + if (caCertPem === undefined) { + throw new CaError("certificate stream carried no CA section"); + } + if (leaves.size === 0) { + throw new CaError("certificate stream carried no host leaves"); } - return { - caCertPem: readFileSync(join(directory, "ca.pem"), "utf8"), - leaves, - }; + return { caCertPem, leaves }; } /** - * Publish the CA certificate where AWF expects it. + * Read the material from a file descriptor, defaulting to stdin. * - * AWF pre-creates this path as a regular file before the sidecar starts, so a - * symlink cannot be swapped in between creation and write. Only the public - * certificate is ever written; the private key stays in the tmpfs directory. + * Read once at startup and held in memory only. A restart therefore has no + * material and fails closed, which is intended: a fresh CA would not be trusted + * by the already-running MCP, so continuing would break every intercepted + * request in a way that looks like a policy error rather than a restart. + */ +export function readCaMaterials(fd: number = 0): CaMaterials { + let raw: string; + try { + raw = readFileSync(fd, "utf8"); + } catch (error) { + throw new CaError(`cannot read certificate material: ${(error as Error).message}`); + } + return parseCaMaterials(raw); +} + +/** + * Publish the CA certificate where the MCP container can mount it. + * + * Only the public certificate is ever written out; the private keys stay in + * this process. */ export function publishCaCertificate(path: string, caCertPem: string): void { + if (PRIVATE_KEY.test(caCertPem)) { + // Defence in depth: this path is mounted into another container, so a key + // reaching it would hand out the ability to impersonate any protected host. + throw new CaError("refusing to publish certificate material containing a private key"); + } writeFileSync(path, caCertPem, { mode: 0o644 }); } diff --git a/scripts/ado-script/src/ado-proxy/index.ts b/scripts/ado-script/src/ado-proxy/index.ts index 35d1fef8d..767631534 100644 --- a/scripts/ado-script/src/ado-proxy/index.ts +++ b/scripts/ado-script/src/ado-proxy/index.ts @@ -18,18 +18,15 @@ * decision. * * The bearer is never in argv or the environment: it is read from a private - * file the trusted host task rotates. Only the *public* interception - * certificate is ever written out. + * file the trusted host task rotates. The interception certificates arrive on + * stdin from a host generation step, so their private keys touch no filesystem; + * only the *public* CA certificate is ever written out. * * Unlike the other `ado-script` bundles, which are short-lived pipeline steps, * this one is a long-running server: it starts before the agent and is torn * down by AWF when the agent exits. */ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { CaError, mintCa, publishCaCertificate } from "./ca.js"; +import { CaError, publishCaCertificate, readCaMaterials } from "./ca.js"; import { ConfigError, loadConfig, type ProxyConfig } from "./config.js"; import { DecisionLog } from "./log.js"; import { createProxyServer } from "./server.js"; @@ -40,22 +37,6 @@ function report(message: string): void { process.stderr.write(`[ado-proxy] ${message}\n`); } -/** - * Where the CA private key lives. - * - * AWF mounts tmpfs at this path so the key never touches a host filesystem or - * any volume the agent can see. When it is absent — as in tests — fall back to - * a private temporary directory rather than failing, since the key is - * regenerated per process either way. - */ -function keyDirectory(): string { - const configured = process.env.AWF_POLICY_PROXY_TMPFS_DIR; - if (configured !== undefined && configured !== "") { - return join(configured, "ado-proxy-ca"); - } - return mkdtempSync(join(tmpdir(), "ado-proxy-ca-")); -} - /** Start the proxy and resolve with the process exit code once it stops. */ export async function run(argv: readonly string[]): Promise { let config: ProxyConfig; @@ -79,14 +60,15 @@ export async function run(argv: readonly string[]): Promise { let ca; try { - ca = mintCa(keyDirectory(), config.policy.protected_hosts); + // Read before anything else binds a port: without an interception identity + // there is nothing safe to serve, and the agent's clients would reject + // interception anyway. The only "fix" for that would be to stop + // intercepting, which is exactly what this proxy exists to prevent. + ca = readCaMaterials(); publishCaCertificate(config.publicCaFile, ca.caCertPem); } catch (error) { if (!(error instanceof CaError)) throw error; - // Without a trusted CA the agent's clients reject interception, and the - // only "fix" would be to stop intercepting — which is the thing this proxy - // exists to prevent. - report(`cannot establish the interception CA: ${error.message}`); + report(`cannot establish the interception identity: ${error.message}`); return 1; } diff --git a/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts index 21134a8da..daaf6e681 100644 --- a/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts +++ b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts @@ -18,7 +18,7 @@ * fails here. */ import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer as createHttpServer, request as httpRequest, type Server } from "node:http"; import { connect as netConnect, type Socket } from "node:net"; import { tmpdir } from "node:os"; @@ -31,7 +31,7 @@ import { import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { mintCa, type CaMaterials } from "./ca.js"; +import { parseCaMaterials, type CaMaterials } from "./ca.js"; import { CATALOG_SCHEMA_VERSION } from "./catalog.js"; import type { ProxyConfig, ProxyPolicy } from "./config.js"; import { DecisionLog } from "./log.js"; @@ -113,6 +113,51 @@ function listen(server: { listen: (...args: never[]) => void }): Promise }); } +/** + * Mint CA material in the stream format the engine consumes. + * + * The engine no longer mints its own certificates — a host pipeline step does, + * and pipes them in — so this stands in for that step. It returns the parsed + * form for the harness's own servers, and the raw stream for feeding the engine. + */ +function mintForTest(directory: string, hosts: readonly string[]): { + materials: CaMaterials; + stream: string; +} { + mkdirSync(directory, { recursive: true }); + const run = (args: readonly string[]): void => { + execFileSync("openssl", args as string[], { + cwd: directory, + stdio: ["ignore", "ignore", "pipe"], + }); + }; + + run([ + "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", + "-subj", "/CN=ado-proxy test CA", "-keyout", "ca.key", "-out", "ca.pem", + "-addext", "basicConstraints=critical,CA:TRUE,pathlen:0", + ]); + + let stream = `### CA\n${readFileSync(join(directory, "ca.pem"), "utf8")}`; + + for (const host of hosts) { + writeFileSync(join(directory, "leaf.ext"), + "basicConstraints=CA:FALSE\n" + + "keyUsage=critical,digitalSignature,keyEncipherment\n" + + "extendedKeyUsage=serverAuth\n" + + `subjectAltName=DNS:${host}\n`); + run(["req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", `/CN=${host}`, + "-keyout", "leaf.key", "-out", "leaf.csr"]); + run(["x509", "-req", "-in", "leaf.csr", "-CA", "ca.pem", "-CAkey", "ca.key", + "-CAcreateserial", "-days", "2", "-extfile", "leaf.ext", "-out", "leaf.pem"]); + stream += `### HOST ${host}\n` + + readFileSync(join(directory, "leaf.key"), "utf8") + + readFileSync(join(directory, "leaf.pem"), "utf8"); + } + + return { materials: parseCaMaterials(stream), stream }; +} + /** A TLS server standing in for `dev.azure.com`. */ async function startFakeAdo(ca: CaMaterials, calls: UpstreamCall[]): Promise { const leaf = ca.leaves.get("dev.azure.com"); @@ -328,9 +373,9 @@ beforeAll(async () => { if (!hasOpenssl) return; workdir = mkdtempSync(join(tmpdir(), "ado-proxy-e2e-")); - const upstreamCa = mintCa(join(workdir, "upstream-ca"), ["dev.azure.com"]); - const plainCa = mintCa(join(workdir, "plain-ca"), ["example.test"]); - const proxyCa = mintCa(join(workdir, "proxy-ca"), POLICY.protected_hosts); + const upstreamCa = mintForTest(join(workdir, "upstream-ca"), ["dev.azure.com"]).materials; + const plainCa = mintForTest(join(workdir, "plain-ca"), ["example.test"]).materials; + const proxyCa = mintForTest(join(workdir, "proxy-ca"), POLICY.protected_hosts).materials; const upstreamCalls: UpstreamCall[] = []; const tunnelTargets: string[] = []; From 13e15359877804b51fc7ca45778233b2bb0564ae Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 2 Aug 2026 08:20:29 +0100 Subject: [PATCH 09/42] feat(ado-proxy): accept direct TLS alongside the CONNECT path The engine only spoke proxy protocol: a CONNECT, or an absolute-form request. But neither production client uses a proxy. The Azure DevOps MCP is redirected by --add-host, and the az wrapper is pointed at the engine hostname; both open a TLS connection directly and would have handed raw handshake bytes to an HTTP parser. Add a second listener that terminates TLS straight off the socket, choosing the host by SNI rather than a CONNECT target. Everything after the handshake - normalization, catalog enforcement, credential injection, response filtering - is the identical code path, so there is one policy implementation with two ingresses rather than two implementations to keep in step. The CONNECT listener stays for proxy-configured clients; the design doc already marks the byte-tunnel as a compatibility affordance. Hardening: a TLS handshake that fails (unknown SNI, a client that does not trust the CA) arrives as a tlsClientError, and pre-handshake socket errors have no other handler. Both are now caught, since a client resetting mid-handshake must not take down a process serving every other client. New --tls-port option, defaulting to 443 because a redirected client uses the ordinary HTTPS port; configurable only so tests can bind unprivileged. Three e2e tests, all against the assembled server: an allowed read over direct TLS carries the injected bearer and returns 200; a denied one returns 403 without the upstream being contacted; a handshake for a host the catalog does not police is refused outright rather than served with some other leaf. 908 TS tests green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- scripts/ado-script/src/ado-proxy/config.ts | 11 ++ scripts/ado-script/src/ado-proxy/index.ts | 26 ++++- .../src/ado-proxy/proxy.e2e.test.ts | 103 +++++++++++++++++- scripts/ado-script/src/ado-proxy/server.ts | 62 +++++++++-- 4 files changed, 183 insertions(+), 19 deletions(-) diff --git a/scripts/ado-script/src/ado-proxy/config.ts b/scripts/ado-script/src/ado-proxy/config.ts index 75d31469d..c48663b69 100644 --- a/scripts/ado-script/src/ado-proxy/config.ts +++ b/scripts/ado-script/src/ado-proxy/config.ts @@ -29,6 +29,15 @@ export interface ProxyConfig { readonly publicCaFile: string; /** Directory for the sanitized JSONL decision log, when configured. */ readonly logDir?: string; + /** + * Port for direct TLS, where clients connect believing they are talking to + * Azure DevOps itself. + * + * Defaults to 443, since a client redirected by `--add-host` or pointed at + * the engine's hostname uses the ordinary HTTPS port. Configurable only so + * tests can run unprivileged. + */ + readonly tlsPort: number; /** The scope and capability policy this proxy enforces. */ readonly policy: ProxyPolicy; } @@ -259,12 +268,14 @@ export function loadConfig(argv: readonly string[]): ProxyConfig { const listenPortRaw = readOption(argv, "listen-port", "AWF_POLICY_PROXY_LISTEN_PORT") ?? "11080"; + const tlsPortRaw = readOption(argv, "tls-port", "ADO_PROXY_TLS_PORT") ?? "443"; return { listenAddress: readOption(argv, "listen-address", "AWF_POLICY_PROXY_LISTEN_ADDRESS") ?? "0.0.0.0", listenPort: parsePort(listenPortRaw, "--listen-port"), + tlsPort: parsePort(tlsPortRaw, "--tls-port"), upstreamProxy: requireOption( argv, "upstream-proxy", diff --git a/scripts/ado-script/src/ado-proxy/index.ts b/scripts/ado-script/src/ado-proxy/index.ts index 767631534..829fe6fb8 100644 --- a/scripts/ado-script/src/ado-proxy/index.ts +++ b/scripts/ado-script/src/ado-proxy/index.ts @@ -29,7 +29,7 @@ import { CaError, publishCaCertificate, readCaMaterials } from "./ca.js"; import { ConfigError, loadConfig, type ProxyConfig } from "./config.js"; import { DecisionLog } from "./log.js"; -import { createProxyServer } from "./server.js"; +import { createDirectTlsServer, createProxyServer } from "./server.js"; import { TokenSource } from "./token.js"; import { UpstreamError, parseUpstreamProxy } from "./upstream.js"; @@ -72,20 +72,30 @@ export async function run(argv: readonly string[]): Promise { return 1; } - const server = createProxyServer({ + const deps = { config, ca, tokens: new TokenSource(config.tokenFile), log: new DecisionLog(config.logDir), - }); + }; + const server = createProxyServer(deps); + const tlsServer = createDirectTlsServer(deps); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(config.listenPort, config.listenAddress, resolve); }); + // The direct-TLS listener is what the redirected MCP and the `az` wrapper + // actually use; the proxy-style listener above remains for CONNECT clients. + await new Promise((resolve, reject) => { + tlsServer.once("error", reject); + tlsServer.listen(config.tlsPort, config.listenAddress, resolve); + }); + report( - `listening on ${config.listenAddress}:${config.listenPort}; ` + + `listening on ${config.listenAddress}:${config.listenPort} (proxy) and ` + + `${config.listenAddress}:${config.tlsPort} (direct TLS); ` + `org=${config.policy.organization} project=${config.policy.project} ` + `capabilities=${config.policy.capabilities.join(",") || "(none)"} ` + `protected=${config.policy.protected_hosts.join(",")}`, @@ -96,7 +106,13 @@ export async function run(argv: readonly string[]): Promise { await new Promise((resolve) => { const shutdown = (signal: string): void => { report(`received ${signal}; shutting down`); - server.close(() => resolve()); + let remaining = 2; + const done = (): void => { + remaining -= 1; + if (remaining === 0) resolve(); + }; + server.close(done); + tlsServer.close(done); setTimeout(resolve, 5_000).unref(); }; process.once("SIGTERM", () => shutdown("SIGTERM")); diff --git a/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts index daaf6e681..90e6e59b2 100644 --- a/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts +++ b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts @@ -35,7 +35,7 @@ import { parseCaMaterials, type CaMaterials } from "./ca.js"; import { CATALOG_SCHEMA_VERSION } from "./catalog.js"; import type { ProxyConfig, ProxyPolicy } from "./config.js"; import { DecisionLog } from "./log.js"; -import { HEALTH_PATH, createProxyServer } from "./server.js"; +import { createDirectTlsServer, HEALTH_PATH, createProxyServer } from "./server.js"; import { TokenSource } from "./token.js"; /** @@ -93,6 +93,7 @@ interface UpstreamCall { interface Harness { readonly proxyPort: number; + readonly directTlsPort: number; readonly proxyCaPem: string; readonly upstreamCalls: UpstreamCall[]; readonly tunnelTargets: string[]; @@ -369,6 +370,49 @@ function plainHttpThroughProxy(proxyPort: number, target: string): Promise; ca: string }, +): Promise { + return new Promise((resolve, reject) => { + const secured = tlsConnect({ host: "127.0.0.1", port, servername: host, ca: options.ca }, () => { + const headerLines = Object.entries(options.headers ?? {}) + .map(([name, value]) => `${name}: ${value}\r\n`) + .join(""); + secured.write( + `${options.method ?? "GET"} ${path} HTTP/1.1\r\nHost: ${host}\r\n` + + `${headerLines}Connection: close\r\n\r\n`, + ); + }); + + let raw = ""; + secured.on("data", (chunk: Buffer) => { + raw += chunk.toString("utf8"); + }); + secured.on("error", reject); + secured.on("close", () => { + const headerEnd = raw.indexOf("\r\n\r\n"); + const head = headerEnd === -1 ? raw : raw.slice(0, headerEnd); + const body = headerEnd === -1 ? "" : raw.slice(headerEnd + 4); + const headers: Record = {}; + for (const line of head.split("\r\n").slice(1)) { + const colon = line.indexOf(":"); + if (colon === -1) continue; + headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim(); + } + resolve({ status: Number(head.split("\r\n")[0]?.split(" ")[1] ?? 0), body, headers }); + }); + }); +} + beforeAll(async () => { if (!hasOpenssl) return; workdir = mkdtempSync(join(tmpdir(), "ado-proxy-e2e-")); @@ -397,6 +441,8 @@ beforeAll(async () => { const config: ProxyConfig = { listenAddress: "127.0.0.1", listenPort: 0, + // 443 needs privileges; the direct-TLS listener is bound explicitly below. + tlsPort: 0, upstreamProxy: `http://127.0.0.1:${squidPort}`, tokenFile, publicCaFile: join(workdir, "ca.pem"), @@ -413,8 +459,21 @@ beforeAll(async () => { servers.push(server); const proxyPort = await listen(server as never); + // The listener the redirected MCP and the `az` wrapper actually use: no + // CONNECT, just a TLS handshake straight to the port. + const tlsServer = createDirectTlsServer({ + config, + ca: proxyCa, + tokens: new TokenSource(tokenFile), + log: new DecisionLog(join(workdir, "decisions")), + upstreamCa: upstreamCa.caCertPem, + }); + servers.push(tlsServer); + const directTlsPort = await listen(tlsServer as never); + harness = { proxyPort, + directTlsPort, proxyCaPem: proxyCa.caCertPem, upstreamCalls, tunnelTargets, @@ -447,6 +506,46 @@ afterAll(async () => { const suite = hasOpenssl ? describe : describe.skip; suite("ado-proxy end to end", () => { + it("serves an allowed read over direct TLS, with no proxy configuration", async () => { + // How the redirected MCP and the `az` wrapper actually arrive: a TLS + // handshake straight to the port, host chosen by SNI, no CONNECT. + const before = harness.upstreamCalls.length; + const response = await directTlsRequest( + harness.directTlsPort, + "dev.azure.com", + `/${ORGANIZATION}/_apis/projects?api-version=7.1&stateFilter=all&$top=1&$skip=0`, + { ca: harness.proxyCaPem }, + ); + + expect(response.status).toBe(200); + // The same enforcement applies as on the CONNECT path — one policy + // implementation, two ingresses. + expect(harness.upstreamCalls[before]?.authorization).toBe(`Bearer ${CANARY}`); + expect(response.body).not.toContain(CANARY); + }); + + it("denies over direct TLS without contacting the upstream", async () => { + const before = harness.upstreamCalls.length; + const response = await directTlsRequest( + harness.directTlsPort, + "dev.azure.com", + `/${ORGANIZATION}/_apis/serviceendpoint/endpoints?api-version=7.1`, + { ca: harness.proxyCaPem }, + ); + expect(response.status).toBe(403); + expect(harness.upstreamCalls.length).toBe(before); + }); + + it("refuses a TLS handshake for a host it does not police", async () => { + // No leaf exists for it, so there is nothing to impersonate — and serving + // one would mean intercepting traffic the catalog has no rules for. + await expect( + directTlsRequest(harness.directTlsPort, "example.test", "/", { + ca: harness.proxyCaPem, + }), + ).rejects.toThrow(); + }); + it("injects the bearer only on an allowed read, and strips the client's", async () => { const before = harness.upstreamCalls.length; const response = await requestThroughProxy( @@ -545,8 +644,6 @@ suite("ado-proxy end to end", () => { }); it("answers the readiness probe without revealing policy detail", async () => { - // AWF polls this before starting the agent so the agent cannot race a - // proxy that has not finished minting its CA. const response = await plainHttpThroughProxy(harness.proxyPort, HEALTH_PATH); expect(response.status).toBe(200); expect(JSON.parse(response.body)).toEqual({ status: "ok" }); diff --git a/scripts/ado-script/src/ado-proxy/server.ts b/scripts/ado-script/src/ado-proxy/server.ts index 782a80fbd..8020d7316 100644 --- a/scripts/ado-script/src/ado-proxy/server.ts +++ b/scripts/ado-script/src/ado-proxy/server.ts @@ -18,7 +18,7 @@ import { randomUUID } from "node:crypto"; import { createServer as createHttpServer, type IncomingMessage, type ServerResponse, request as httpRequest } from "node:http"; import type { Server } from "node:http"; -import type { Socket } from "node:net"; +import { createServer as createNetServer, type Server as NetServer, type Socket } from "node:net"; import { connect as tlsConnect, createSecureContext, createServer as createTlsServer, type TLSSocket } from "node:tls"; import type { CaMaterials } from "./ca.js"; @@ -418,7 +418,12 @@ async function handleProtected( * Sockets are handed to an inner HTTP server so Node parses the tunnelled * requests for us. */ -function createInterceptor(deps: ProxyDeps): (socket: Socket, host: string) => void { +function createInterceptor(deps: ProxyDeps): { + /** Hand an already-accepted socket to the TLS terminator. */ + attach: (socket: Socket, host: string) => void; + /** Accept a socket whose host is not yet known — SNI decides. */ + accept: (socket: Socket) => void; +} { const inner = createHttpServer((request, response) => { const socket = request.socket as TLSSocket; const host = canonicalizeHost(socket.servername || ""); @@ -438,8 +443,10 @@ function createInterceptor(deps: ProxyDeps): (socket: Socket, host: string) => v const leaf = deps.ca.leaves.get(canonicalizeHost(servername)); if (leaf === undefined) { // Only compiler-pinned protected hosts have leaves. Anything else - // reaching the interceptor is a mismatch between the CONNECT target and - // the SNI, which is a smuggling attempt, not a supported client. + // reaching the interceptor is either a CONNECT-target/SNI mismatch or a + // client that resolved us for a host we do not police — neither is + // supported, and serving it would mean impersonating something the + // policy has no rules for. callback(new Error(`no certificate for ${servername}`)); return; } @@ -447,13 +454,19 @@ function createInterceptor(deps: ProxyDeps): (socket: Socket, host: string) => v }, }); tls.on("secureConnection", (socket) => inner.emit("connection", socket)); + // A handshake failure (unknown SNI, untrusting client) must not be an + // unhandled 'error' event; the process serves every other client too. + tls.on("tlsClientError", (_error, socket) => socket.destroy()); - return (socket: Socket, host: string) => { - if (!deps.ca.leaves.has(host)) { - socket.destroy(); - return; - } - tls.emit("connection", socket); + return { + attach: (socket: Socket, host: string) => { + if (!deps.ca.leaves.has(host)) { + socket.destroy(); + return; + } + tls.emit("connection", socket); + }, + accept: (socket: Socket) => tls.emit("connection", socket), }; } @@ -534,7 +547,7 @@ export function createProxyServer(deps: ProxyDeps): Server { } if (head.length > 0) socket.unshift(head); socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); - intercept(socket, host); + intercept.attach(socket, host); return; } void tunnel(deps, socket, head, host, port); @@ -547,3 +560,30 @@ export function createProxyServer(deps: ProxyDeps): Server { return server; } + +/** + * Create the direct-TLS listener. + * + * The proxy-style listener above expects HTTP — a `CONNECT`, or an + * absolute-form request. But the two production clients do not use a proxy at + * all: the Azure DevOps MCP is redirected by `--add-host`, and the `az` wrapper + * is pointed at the engine's own hostname. Both therefore open a TLS + * connection *directly* and would otherwise hand raw handshake bytes to an + * HTTP parser. + * + * The host is chosen by SNI rather than a `CONNECT` target, and everything + * after the handshake — normalization, catalog enforcement, credential + * injection, response filtering — is the identical code path. There is no + * second policy implementation. + */ +export function createDirectTlsServer(deps: ProxyDeps): NetServer { + const intercept = createInterceptor(deps); + const server = createNetServer((socket) => { + // Errors before the handshake completes belong to the raw socket, which has + // no other handler; without this a client that resets mid-handshake would + // take the process down. + socket.on("error", () => socket.destroy()); + intercept.accept(socket); + }); + return server; +} From 622dac06adeddc8caac79a915d500af10dc5caca Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 2 Aug 2026 08:47:06 +0100 Subject: [PATCH 10/42] docs(ado-proxy): mount the MCP package; record the credential-delivery constraint Two findings from probing the MCP path, plus a gap they exposed. MCP packaging: mount, do not pre-bake -------------------------------------- @azure-devops/mcp@2.8.1 installed on the host and mounted read-only into an unchanged node:20-slim completed an MCP initialize handshake under --network none, returning full tool capabilities. So no pre-baked image is needed, and nothing new enters the supply chain for air-gapped customers - the package is handled exactly as ado-script.zip already is. Two implementation details that are easy to get wrong: - the mount must be at /app/node_modules, not an arbitrary path. Node resolves dependencies by walking upward from the importing file, so mounting elsewhere leaves the MCP own dependencies unresolvable (ERR_MODULE_NOT_FOUND for @modelcontextprotocol/sdk). - the startup tenant lookup in org-tenants.js targets vssps.dev.azure.com, which is not in the protected set. It fails under isolation, the server logs it and continues - so it neither blocks startup nor needs a catalog entry. Credential delivery is not yet wired, and the obvious path is unsafe -------------------------------------------------------------------- Acquisition exists (generate_acquire_ado_token -> SC_READ_TOKEN), but nothing produces the --token-file the engine reads. The obvious choice - a file under the runner /tmp - would be a security bug, for the same reason the CA private key was: AWF mounts /tmp into the agent at both /tmp and /host/tmp (agent-service.ts), which is exactly how AWF installs its own gh wrapper. A token written there is agent-readable and the boundary is gone. Recorded with the two mechanisms that avoid a shared path, tracked as proxy-token-delivery. Separately, the compiler currently passes -e ADO_MCP_AUTH_TOKEN="\" straight into the MCP container. Under interception the engine holds the credential and injects it after an allow decision, so the MCP must receive a non-secret sentinel instead; leaving the real token there would make the proxy decorative on that path. Tracked as mcp-token-sentinel. Docs only; no runtime behaviour changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index 97448aa23..0945fde6f 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -245,6 +245,40 @@ rather than bypassed at either. This is load-bearing and observable: during testing the engine correctly refused a self-signed upstream with `unable to verify the first certificate`. +### Credential delivery + +Acquisition already exists: `generate_acquire_ado_token` emits an `AzureCLI@2` +step that mints an ADO-audience token from the ARM service connection and stores +it as the secret pipeline variable `SC_READ_TOKEN`. + +**Delivery must not use a runner path.** The engine reads its bearer from +`--token-file`, and the obvious choice — a file under the runner's `/tmp` — +is unsafe for the same reason the CA private key was: AWF mounts `/tmp` into the +agent at both `/tmp` and `/host/tmp` (`agent-service.ts`), which is exactly how +AWF installs its own `gh` wrapper. A token written there is agent-readable, and +the boundary is gone. + +Two mechanisms avoid a shared path, both to be settled by +`proxy-token-delivery`: + +- **stdin**, alongside the CA material — simplest, but one-shot, so it cannot + rotate; +- **`docker cp` into the running container**, or a named volume mounted only + into the engine — supports rotation, at the cost of a refresh loop running + during the agent step. + +An ADO access token is typically valid ~1 hour, so a single token covers most +runs; rotation matters for long ones. WIF assertions are much shorter +(~5–10 min), but they are consumed at mint time and never reach the engine. + +**The MCP must stop receiving the real token.** Today the compiler passes +`-e ADO_MCP_AUTH_TOKEN="$SC_READ_TOKEN"` straight into the MCP container. Under +interception the engine holds the credential and injects it after an allow +decision, so the MCP must be given a non-secret sentinel instead. Leaving the +real token in its environment would make the proxy decorative on that path: the +MCP could still authenticate directly if it ever reached Azure DevOps another +way. + ### Credential renewal Production must support WIF renewal beyond the original assertion lifetime. @@ -396,6 +430,8 @@ re-derived. | **The redirect is narrow** | In the same run an unrelated host failed `ENOTFOUND` — only the named host is affected | | **SPS is avoidable, and `az` completes entirely against the policy endpoint** | Three scenarios (`scripts/sps-probe.mjs`): a *minimal* discovery document fails (`location` area not registered); *faithful* document + a sparse area list falls back to `app.vssps.visualstudio.com`; *faithful* document + a **complete** area list — real area GUIDs, every `locationUrl` pointing back at the endpoint — completed with **exit 0** and never contacted SPS | | **Stock `az` works end to end through the real bundle with no real credential** | With the rewrite implemented, `az devops project list` and `az repos show` both returned **exit 0** and correct JSON. The fake upstream deliberately advertised `vsrm.dev.azure.com`; `az` stayed on the policed origin throughout, and SPS was never contacted. Every request was matched to a catalogued operation (`discovery.host-options`, `discovery.resource-areas`, `core.project-validation-probe`, `repos.repository-get`); the sentinel PAT never reached the upstream and the injected bearer did | +| **The MCP runs from a host-installed mount, with no network at all** | `@azure-devops/mcp@2.8.1` installed on the host and mounted read-only at `/app/node_modules` completed an MCP `initialize` handshake inside `node:20-slim` with `--network none`, returning its full tool capabilities. No pre-baked image is needed, so nothing new enters the supply chain | +| **The MCP's startup tenant lookup is non-fatal** | In that run `org-tenants.js` failed its `fetchTenantFromApi` call (`TypeError: fetch failed`) and the server logged the error and carried on serving. It targets `vssps.dev.azure.com`, which is *not* in the protected set, so under interception it will fail the same way rather than blocking startup | | Upstream verification is real | The engine refused a self-signed upstream with `unable to verify the first certificate` | | Denials surface usefully to clients | `az` printed the engine's `WrappedException` message verbatim | From db3282b3481506c7baebe1e713fa9d30339dce8b Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 2 Aug 2026 17:14:42 +0100 Subject: [PATCH 11/42] feat(ado-proxy): deliver the bearer on stdin and bound the run to its lifetime The engine read its bearer from --token-file, and nothing produced that file. The obvious implementation would have been a security bug: AWF mounts the runner /tmp into the agent at both /tmp and /host/tmp (agent-service.ts), which is exactly how AWF installs its own gh wrapper, so a token written to a runner path is readable by the very agent the credential is being hidden from. The token now travels in the same stdin stream as the interception certificates, under a ### TOKEN section. Same custody property as the CA private key: it touches no filesystem, and the engine starts before AWF so no agent exists while it is being delivered. --token-file and ADO_PROXY_TOKEN_FILE are removed rather than left as a trap. TokenSource becomes an in-memory holder that rejects an empty bearer at construction, so no request path can forward unauthenticated - Azure DevOps answers those with a sign-in page a client can mistake for data. The cost is that a stdin-delivered token cannot rotate, so a run must not outlive it. Rather than let that surface mid-run as opaque 502s - worse than today, since the agent cannot tell an expired credential from a policy denial - it is enforced at compile time: validate_proxied_timeout rejects timeout-minutes above 50 (ADO tokens are ~1h; the margin covers minting before the Agent job starts, plus clock skew). The error names both the limit and the reason. The bound applies only to workflows that opt into the proxy. An unproxied agent holds no Azure DevOps credential, so there is nothing to expire and no reason to constrain it. Rotation needs a different delivery mechanism - a private volume, or docker cp into the running container - and remains required by the WIF-renewal production gate in the design doc. This lands the security fix without pretending that is solved. Clippy clean, 19 Rust suites, 910 TS tests green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- scripts/ado-script/src/ado-proxy/ca.test.ts | 35 +++++- scripts/ado-script/src/ado-proxy/ca.ts | 26 ++++- .../ado-script/src/ado-proxy/config.test.ts | 9 -- scripts/ado-script/src/ado-proxy/config.ts | 3 - scripts/ado-script/src/ado-proxy/index.ts | 2 +- .../src/ado-proxy/proxy.e2e.test.ts | 61 +++------- .../ado-script/src/ado-proxy/token.test.ts | 62 +++------- scripts/ado-script/src/ado-proxy/token.ts | 109 ++++++------------ src/compile/agentic_pipeline.rs | 3 + src/compile/common.rs | 79 +++++++++++++ 10 files changed, 214 insertions(+), 175 deletions(-) diff --git a/scripts/ado-script/src/ado-proxy/ca.test.ts b/scripts/ado-script/src/ado-proxy/ca.test.ts index 9eab69020..55792b52e 100644 --- a/scripts/ado-script/src/ado-proxy/ca.test.ts +++ b/scripts/ado-script/src/ado-proxy/ca.test.ts @@ -22,12 +22,13 @@ function stream(...sections: string[]): string { } const CA_SECTION = `### CA\n${CERT}`; +const TOKEN_SECTION = "### TOKEN\ncanary-bearer\n"; const host = (name: string): string => `### HOST ${name}\n${KEY}${CERT}`; describe("parseCaMaterials", () => { it("parses a CA and its leaves", () => { const materials = parseCaMaterials( - stream(CA_SECTION, host("dev.azure.com"), host("app.vssps.visualstudio.com")), + stream(CA_SECTION, TOKEN_SECTION, host("dev.azure.com"), host("app.vssps.visualstudio.com")), ); expect(materials.caCertPem).toContain("BEGIN CERTIFICATE"); expect([...materials.leaves.keys()].sort()).toEqual([ @@ -38,7 +39,7 @@ describe("parseCaMaterials", () => { }); it("lowercases hostnames so SNI lookup cannot miss on case", () => { - const materials = parseCaMaterials(stream(CA_SECTION, host("DEV.Azure.COM"))); + const materials = parseCaMaterials(stream(CA_SECTION, TOKEN_SECTION, host("DEV.Azure.COM"))); expect(materials.leaves.has("dev.azure.com")).toBe(true); }); @@ -55,7 +56,31 @@ describe("parseCaMaterials", () => { it("rejects a stream with no leaves", () => { // Without a leaf there is nothing to serve, so every intercepted request // would fail at handshake time with no clue as to why. - expect(() => parseCaMaterials(CA_SECTION)).toThrow(/no host leaves/); + expect(() => parseCaMaterials(stream(CA_SECTION, TOKEN_SECTION))).toThrow( + /no host leaves/, + ); + }); + + it("rejects a stream with no bearer", () => { + // Certificates without a credential would mean every allowed request is + // forwarded unauthenticated, and Azure DevOps answers those with a sign-in + // page a client can mistake for data. + expect(() => parseCaMaterials(stream(CA_SECTION, host("dev.azure.com")))).toThrow( + /no Azure DevOps bearer/, + ); + }); + + it("rejects an empty bearer section", () => { + expect(() => + parseCaMaterials(stream(CA_SECTION, "### TOKEN\n \n", host("dev.azure.com"))), + ).toThrow(/no Azure DevOps bearer/); + }); + + it("carries the bearer through", () => { + const materials = parseCaMaterials( + stream(CA_SECTION, TOKEN_SECTION, host("dev.azure.com")), + ); + expect(materials.token).toBe("canary-bearer"); }); it("rejects a half-formed leaf rather than serving it", () => { @@ -83,7 +108,7 @@ describe("parseCaMaterials", () => { // Forward compatibility: a generator adding a section this build does not // know about must not take the proxy down. const materials = parseCaMaterials( - stream(CA_SECTION, "### FUTURE thing\nwhatever\n", host("dev.azure.com")), + stream(CA_SECTION, TOKEN_SECTION, "### FUTURE thing\nwhatever\n", host("dev.azure.com")), ); expect(materials.leaves.size).toBe(1); }); @@ -129,7 +154,7 @@ describe("readCaMaterials", () => { it("reads and parses from a descriptor", () => { const path = join(directory, "material.pem"); - writeFileSync(path, stream(CA_SECTION, host("dev.azure.com"))); + writeFileSync(path, stream(CA_SECTION, TOKEN_SECTION, host("dev.azure.com"))); // eslint-disable-next-line @typescript-eslint/no-require-imports const { openSync, closeSync } = require("node:fs") as typeof import("node:fs"); const fd = openSync(path, "r"); diff --git a/scripts/ado-script/src/ado-proxy/ca.ts b/scripts/ado-script/src/ado-proxy/ca.ts index 08ad0dffa..b5524a380 100644 --- a/scripts/ado-script/src/ado-proxy/ca.ts +++ b/scripts/ado-script/src/ado-proxy/ca.ts @@ -37,6 +37,14 @@ export interface CaMaterials { readonly caCertPem: string; /** Leaf key/cert per host, keyed by lowercase hostname. */ readonly leaves: ReadonlyMap; + /** + * The Azure DevOps bearer. + * + * Carried in the same stream as the certificates because it has the same + * custody requirement: it must reach this process without touching a path + * the agent can read. + */ + readonly token: string; } /** @@ -48,6 +56,8 @@ export interface CaMaterials { */ const CA_MARKER = "### CA"; const HOST_MARKER = "### HOST "; +const TOKEN_MARKER = "### TOKEN"; + const PRIVATE_KEY = /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC )?PRIVATE KEY-----/; @@ -71,6 +81,7 @@ export function parseCaMaterials(raw: string): CaMaterials { const sections = raw.split("### ").slice(1); let caCertPem: string | undefined; + let token: string | undefined; const leaves = new Map(); for (const section of sections) { @@ -83,8 +94,15 @@ export function parseCaMaterials(raw: string): CaMaterials { continue; } + if (body.startsWith(TOKEN_MARKER)) { + const newline = body.indexOf("\n"); + token = newline === -1 ? "" : body.slice(newline + 1).trim(); + continue; + } + if (!body.startsWith(HOST_MARKER)) continue; + const newline = body.indexOf("\n"); const host = body .slice(HOST_MARKER.length, newline === -1 ? undefined : newline) @@ -110,8 +128,14 @@ export function parseCaMaterials(raw: string): CaMaterials { if (leaves.size === 0) { throw new CaError("certificate stream carried no host leaves"); } + if (token === undefined || token === "") { + // Starting without a bearer would mean every allowed request is forwarded + // unauthenticated, and Azure DevOps answers those with a sign-in page a + // client can mistake for data. Refuse instead. + throw new CaError("stream carried no Azure DevOps bearer"); + } - return { caCertPem, leaves }; + return { caCertPem, leaves, token }; } /** diff --git a/scripts/ado-script/src/ado-proxy/config.test.ts b/scripts/ado-script/src/ado-proxy/config.test.ts index c27b40f01..194b92b08 100644 --- a/scripts/ado-script/src/ado-proxy/config.test.ts +++ b/scripts/ado-script/src/ado-proxy/config.test.ts @@ -32,7 +32,6 @@ function policyJson(overrides: Record = {}): string { /** Env vars the loader reads; cleared so host state cannot leak into a test. */ const PROXY_ENV_KEYS = [ "ADO_PROXY_POLICY_FILE", - "ADO_PROXY_TOKEN_FILE", "AWF_POLICY_PROXY_LISTEN_ADDRESS", "AWF_POLICY_PROXY_LISTEN_PORT", "AWF_POLICY_PROXY_UPSTREAM_PROXY", @@ -123,8 +122,6 @@ describe("loadConfig", () => { const baseArgs = (policyFile: string): string[] => [ "--policy-file", policyFile, - "--token-file", - "/private/token", "--public-ca-file", "/ca/ca.pem", "--upstream-proxy", @@ -143,7 +140,6 @@ describe("loadConfig", () => { const policyFile = writePolicy(); const config = loadConfig([ `--policy-file=${policyFile}`, - "--token-file=/private/token", "--public-ca-file=/ca/ca.pem", "--upstream-proxy=http://squid-proxy:3128", "--listen-port=12000", @@ -154,14 +150,12 @@ describe("loadConfig", () => { it("falls back to the AWF environment contract", () => { const policyFile = writePolicy(); process.env.ADO_PROXY_POLICY_FILE = policyFile; - process.env.ADO_PROXY_TOKEN_FILE = "/private/token"; process.env.AWF_POLICY_PROXY_PUBLIC_CA_PATH = "/ca/ca.pem"; process.env.AWF_POLICY_PROXY_UPSTREAM_PROXY = "http://squid-proxy:3128"; process.env.AWF_POLICY_PROXY_LISTEN_PORT = "13000"; const config = loadConfig([]); expect(config.listenPort).toBe(13000); - expect(config.tokenFile).toBe("/private/token"); }); it("requires an upstream proxy", () => { @@ -172,8 +166,6 @@ describe("loadConfig", () => { loadConfig([ "--policy-file", policyFile, - "--token-file", - "/private/token", "--public-ca-file", "/ca/ca.pem", ]), @@ -200,6 +192,5 @@ describe("loadConfig", () => { // its *path* may appear in configuration. const config = loadConfig(baseArgs(writePolicy())); expect(JSON.stringify(config)).not.toContain("Bearer"); - expect(config.tokenFile).toBe("/private/token"); }); }); diff --git a/scripts/ado-script/src/ado-proxy/config.ts b/scripts/ado-script/src/ado-proxy/config.ts index c48663b69..228f7aa1a 100644 --- a/scripts/ado-script/src/ado-proxy/config.ts +++ b/scripts/ado-script/src/ado-proxy/config.ts @@ -23,8 +23,6 @@ export interface ProxyConfig { readonly listenPort: number; /** Squid URL. The proxy's only route out; there is no direct-internet path. */ readonly upstreamProxy: string; - /** Private file the trusted host task rotates the ADO bearer into. */ - readonly tokenFile: string; /** Pre-created file the public interception certificate is written into. */ readonly publicCaFile: string; /** Directory for the sanitized JSONL decision log, when configured. */ @@ -281,7 +279,6 @@ export function loadConfig(argv: readonly string[]): ProxyConfig { "upstream-proxy", "AWF_POLICY_PROXY_UPSTREAM_PROXY", ), - tokenFile: requireOption(argv, "token-file", "ADO_PROXY_TOKEN_FILE"), publicCaFile: requireOption( argv, "public-ca-file", diff --git a/scripts/ado-script/src/ado-proxy/index.ts b/scripts/ado-script/src/ado-proxy/index.ts index 829fe6fb8..cddbc301b 100644 --- a/scripts/ado-script/src/ado-proxy/index.ts +++ b/scripts/ado-script/src/ado-proxy/index.ts @@ -75,7 +75,7 @@ export async function run(argv: readonly string[]): Promise { const deps = { config, ca, - tokens: new TokenSource(config.tokenFile), + tokens: new TokenSource(ca.token), log: new DecisionLog(config.logDir), }; const server = createProxyServer(deps); diff --git a/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts index 90e6e59b2..ede9acd32 100644 --- a/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts +++ b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts @@ -97,7 +97,7 @@ interface Harness { readonly proxyCaPem: string; readonly upstreamCalls: UpstreamCall[]; readonly tunnelTargets: string[]; - readonly tokenFile: string; + readonly materialStream: string; } let workdir: string; @@ -156,7 +156,7 @@ function mintForTest(directory: string, hosts: readonly string[]): { readFileSync(join(directory, "leaf.pem"), "utf8"); } - return { materials: parseCaMaterials(stream), stream }; + return { materials: parseCaMaterials(`${stream}### TOKEN\n${CANARY}\n`), stream }; } /** A TLS server standing in for `dev.azure.com`. */ @@ -419,7 +419,9 @@ beforeAll(async () => { const upstreamCa = mintForTest(join(workdir, "upstream-ca"), ["dev.azure.com"]).materials; const plainCa = mintForTest(join(workdir, "plain-ca"), ["example.test"]).materials; - const proxyCa = mintForTest(join(workdir, "proxy-ca"), POLICY.protected_hosts).materials; + const proxyMaterial = mintForTest(join(workdir, "proxy-ca"), POLICY.protected_hosts); + const proxyCa = proxyMaterial.materials; + const proxyCaStream = `${proxyMaterial.stream}### TOKEN\n${CANARY}\n`; const upstreamCalls: UpstreamCall[] = []; const tunnelTargets: string[] = []; @@ -435,16 +437,12 @@ beforeAll(async () => { plainHttpPort, ); - const tokenFile = join(workdir, "token"); - writeFileSync(tokenFile, `${CANARY}\n`, { mode: 0o600 }); - const config: ProxyConfig = { listenAddress: "127.0.0.1", listenPort: 0, // 443 needs privileges; the direct-TLS listener is bound explicitly below. tlsPort: 0, upstreamProxy: `http://127.0.0.1:${squidPort}`, - tokenFile, publicCaFile: join(workdir, "ca.pem"), policy: POLICY, }; @@ -452,7 +450,7 @@ beforeAll(async () => { const server = createProxyServer({ config, ca: proxyCa, - tokens: new TokenSource(tokenFile), + tokens: new TokenSource(CANARY), log: new DecisionLog(join(workdir, "decisions")), upstreamCa: upstreamCa.caCertPem, }); @@ -464,7 +462,7 @@ beforeAll(async () => { const tlsServer = createDirectTlsServer({ config, ca: proxyCa, - tokens: new TokenSource(tokenFile), + tokens: new TokenSource(CANARY), log: new DecisionLog(join(workdir, "decisions")), upstreamCa: upstreamCa.caCertPem, }); @@ -477,7 +475,7 @@ beforeAll(async () => { proxyCaPem: proxyCa.caCertPem, upstreamCalls, tunnelTargets, - tokenFile, + materialStream: proxyCaStream, }; // Keep the plain CA reachable for the tunnel assertion. @@ -696,39 +694,18 @@ suite("ado-proxy end to end", () => { expect(response.headers.location).toBeUndefined(); }); - it("fails closed, not unauthenticated, when the credential is missing", async () => { - writeFileSync(harness.tokenFile, " \n", { mode: 0o600 }); - const before = harness.upstreamCalls.length; - try { - const response = await requestThroughProxy( - harness.proxyPort, - "dev.azure.com", - `/${ORGANIZATION}/_apis/projects/Widgets?api-version=7.1`, - { ca: harness.proxyCaPem }, - ); - // 502 rather than 401/429/503: msrest retries those, which would turn one - // failure into several upstream calls. - expect(response.status).toBe(502); - expect(harness.upstreamCalls.length).toBe(before); - } finally { - writeFileSync(harness.tokenFile, `${CANARY}\n`, { mode: 0o600 }); - } + it("refuses to start without a bearer, rather than forwarding unauthenticated", () => { + // A stream carrying certificates but no token must not yield a running + // proxy: Azure DevOps answers an unauthenticated request with a sign-in + // page, which a client can mistake for data. + const withoutToken = harness.materialStream.replace(/### TOKEN\n[\s\S]*$/, ""); + expect(() => parseCaMaterials(withoutToken)).toThrow(/no Azure DevOps bearer/); }); - it("picks up a rotated token without a restart", async () => { - const rotated = "rotated-bearer-1a2b3c4d"; - writeFileSync(harness.tokenFile, `${rotated}\n`, { mode: 0o600 }); - try { - const before = harness.upstreamCalls.length; - await requestThroughProxy( - harness.proxyPort, - "dev.azure.com", - `/${ORGANIZATION}/_apis/projects/Widgets?api-version=7.1`, - { ca: harness.proxyCaPem }, - ); - expect(harness.upstreamCalls[before]?.authorization).toBe(`Bearer ${rotated}`); - } finally { - writeFileSync(harness.tokenFile, `${CANARY}\n`, { mode: 0o600 }); - } + it("keeps the bearer out of argv and the environment", () => { + // The token arrives on stdin precisely so it is not readable from the + // process table or /proc. + expect(process.argv.join(" ")).not.toContain(CANARY); + expect(JSON.stringify(process.env)).not.toContain(CANARY); }); }); diff --git a/scripts/ado-script/src/ado-proxy/token.test.ts b/scripts/ado-script/src/ado-proxy/token.test.ts index 3b4d02858..7dcab775b 100644 --- a/scripts/ado-script/src/ado-proxy/token.test.ts +++ b/scripts/ado-script/src/ado-proxy/token.test.ts @@ -1,56 +1,32 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { TokenError, TokenSource, bearerHeader } from "./token.js"; -let directory: string; -let path: string; - -beforeEach(() => { - directory = mkdtempSync(join(tmpdir(), "ado-proxy-token-")); - path = join(directory, "token"); -}); - -afterEach(() => { - rmSync(directory, { recursive: true, force: true }); -}); - describe("TokenSource", () => { - it("reads and trims the token", () => { - writeFileSync(path, " abc123\n"); - expect(new TokenSource(path).read()).toBe("abc123"); - }); - - it("throws when the file is missing", () => { - // Never returns undefined: an unauthenticated forward would be answered by - // Azure DevOps with a sign-in page the agent could mistake for data. - expect(() => new TokenSource(path).read()).toThrow(TokenError); + it("holds the bearer supplied at construction", () => { + expect(new TokenSource("abc123").read()).toBe("abc123"); }); - it("throws when the file is empty or whitespace", () => { - writeFileSync(path, " \n"); - expect(() => new TokenSource(path).read()).toThrow(TokenError); + it("trims surrounding whitespace", () => { + // The token arrives in a piped stream, so a trailing newline is expected. + expect(new TokenSource(" abc123\n").read()).toBe("abc123"); }); - it("picks up a rotated token", () => { - writeFileSync(path, "first"); - const source = new TokenSource(path); - expect(source.read()).toBe("first"); - // Same length as "first" would leave size unchanged, so this also exercises - // the mtime half of the cache key. - writeFileSync(path, "secnd"); - expect(source.read()).toBe("secnd"); + it("rejects an empty or whitespace-only bearer at construction", () => { + // Fail at startup rather than per request: forwarding unauthenticated + // would make Azure DevOps answer with a sign-in page, which a client can + // mistake for data. + for (const value of ["", " ", "\n\t "]) { + expect(() => new TokenSource(value)).toThrow(TokenError); + } }); - it("stops serving a cached token once the file disappears", () => { - writeFileSync(path, "first"); - const source = new TokenSource(path); - expect(source.read()).toBe("first"); - rmSync(path); - expect(() => source.read()).toThrow(TokenError); + it("returns the same value on every read", () => { + // No rotation by design: the token arrives once, on stdin. The compiler + // bounds `timeout-minutes` so a run cannot outlive it. + const source = new TokenSource("stable"); + expect(source.read()).toBe("stable"); + expect(source.read()).toBe("stable"); }); }); diff --git a/scripts/ado-script/src/ado-proxy/token.ts b/scripts/ado-script/src/ado-proxy/token.ts index 3cb280b27..f49918661 100644 --- a/scripts/ado-script/src/ado-proxy/token.ts +++ b/scripts/ado-script/src/ado-proxy/token.ts @@ -1,93 +1,60 @@ /** * Access to the Azure DevOps bearer. * - * The token lives in a file the trusted host task rotates and mounts read-only - * into this container. It is deliberately *not* passed in argv or the - * environment: both are readable from the process table and from `/proc`, and - * neither can be rotated without restarting the proxy. + * The token arrives on **stdin**, in the same stream as the interception + * certificates, and is held in memory for the life of the process. It is + * deliberately not passed in argv or the environment — both are readable from + * the process table and from `/proc` — and deliberately not written to a file. * - * Reads are cached on the file's mtime and size so the hot path does not stat- - * and-read per request, while a rotation still takes effect on the next - * request rather than at some later refresh tick. + * A file would be the obvious choice, and is unsafe here: AWF mounts the + * runner's `/tmp` into the agent at both `/tmp` and `/host/tmp`, which is how + * AWF installs its own `gh` wrapper. Anything the engine wrote to a runner path + * would therefore be readable by the very agent the credential is being hidden + * from. + * + * **No rotation.** A stdin-delivered token cannot be replaced without + * restarting the process, so the run is bounded by the token's lifetime. The + * compiler enforces that bound up front by refusing to compile a workflow whose + * `timeout-minutes` could outlive the token, which turns a mid-run failure — + * where the agent would see opaque `502`s — into a compile error naming the + * cause. Rotation needs a different delivery mechanism (a private volume, or + * `docker cp` into the running container) and is tracked separately. */ -import { readFileSync, statSync } from "node:fs"; export class TokenError extends Error {} -interface CachedToken { - readonly mtimeMs: number; - readonly size: number; - readonly value: string; -} - +/** + * The bearer, held in memory. + * + * A class rather than a bare string so the token has a single accessor to + * audit, and so a future rotating implementation can replace it without + * touching callers. + */ export class TokenSource { - readonly #path: string; - #cached: CachedToken | undefined; + readonly #value: string; - constructor(path: string) { - this.#path = path; + constructor(value: string) { + const trimmed = value.trim(); + if (trimmed === "") { + throw new TokenError("the Azure DevOps bearer is empty"); + } + this.#value = trimmed; } /** - * Return the current bearer. + * Return the bearer. * - * Throws {@link TokenError} when the file is missing, unreadable, or empty. - * Callers must translate that into an infrastructure failure — never into a - * request forwarded without credentials, which Azure DevOps would answer - * with a sign-in page the agent could mistake for data. + * Infallible by construction: an empty or absent token is rejected at + * startup, so no request path can forward unauthenticated. That matters + * because Azure DevOps answers an unauthenticated request with a sign-in + * page, which a client could mistake for data. */ read(): string { - let stats: ReturnType; - try { - stats = statSync(this.#path); - } catch (error) { - this.#cached = undefined; - throw new TokenError( - `token file ${this.#path} is unavailable: ${(error as Error).message}`, - ); - } - - const cached = this.#cached; - if ( - cached !== undefined && - cached.mtimeMs === stats.mtimeMs && - cached.size === stats.size - ) { - return cached.value; - } - - let raw: string; - try { - raw = readFileSync(this.#path, "utf8"); - } catch (error) { - this.#cached = undefined; - throw new TokenError( - `token file ${this.#path} is unreadable: ${(error as Error).message}`, - ); - } - - const value = raw.trim(); - if (value === "") { - this.#cached = undefined; - throw new TokenError(`token file ${this.#path} is empty`); - } - - this.#cached = { mtimeMs: stats.mtimeMs, size: stats.size, value }; - return value; - } - - /** Drop the cache. Used by tests and after an upstream 401. */ - invalidate(): void { - this.#cached = undefined; + return this.#value; } } -/** - * Build the `Authorization` header value for an authorized request. - * - * Azure DevOps accepts the AAD access token as a bearer; the sentinel PAT the - * agent may have supplied was already stripped by {@link sanitizeRequestHeaders}. - */ +/** Format the bearer for the `Authorization` header. */ export function bearerHeader(token: string): string { return `Bearer ${token}`; } diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 4e80f9701..fc8d8d9e2 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -135,6 +135,9 @@ pub(crate) fn build_pipeline_context( // ─── Validations (reuse all shared validators) ──────────────── common::validate_front_matter_identity(front_matter)?; common::validate_permissions_read_policy(front_matter)?; + if let Some(minutes) = front_matter.engine.timeout_minutes() { + common::validate_proxied_timeout(front_matter, minutes)?; + } common::validate_variable_groups(front_matter)?; common::validate_checkout_self_collision( &front_matter.repositories, diff --git a/src/compile/common.rs b/src/compile/common.rs index f70cc2bf0..9d40e8435 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -453,6 +453,46 @@ pub fn validate_front_matter_identity(front_matter: &FrontMatter) -> Result<()> Ok(()) } +/// Longest `timeout-minutes` a proxied workflow may declare. +/// +/// The engine receives one Azure DevOps bearer on stdin and cannot rotate it, +/// so a run must not outlive the token. Azure DevOps access tokens are +/// typically valid for about an hour; 50 minutes leaves headroom for the token +/// being minted before the Agent job starts and for clock skew. +/// +/// This is a *compile-time* bound on purpose. Without it the failure surfaces +/// mid-run as opaque `502`s from the engine — worse than today's behaviour, +/// because the agent cannot tell an expired credential from a policy denial. +/// Raising it requires a rotating delivery mechanism, not a bigger number. +pub const MAX_PROXIED_TIMEOUT_MINUTES: u32 = 50; + +/// Reject a `timeout-minutes` that could outlive the proxy's bearer. +/// +/// Only applies once a workflow opts into the proxied read path; unproxied +/// workflows are unaffected, since their agent holds no Azure DevOps +/// credential to expire. +pub fn validate_proxied_timeout(front_matter: &FrontMatter, timeout_minutes: u32) -> Result<()> { + if timeout_minutes <= MAX_PROXIED_TIMEOUT_MINUTES { + return Ok(()); + } + let uses_proxy = front_matter + .permissions + .as_ref() + .and_then(|permissions| permissions.read.as_ref()) + .and_then(crate::compile::types::ReadPermissionConfig::options) + .is_some(); + if !uses_proxy { + return Ok(()); + } + anyhow::bail!( + "timeout-minutes: {timeout_minutes} exceeds the maximum of \ + {MAX_PROXIED_TIMEOUT_MINUTES} for workflows using the credential-isolated Azure \ + DevOps proxy. The proxy holds a single Azure DevOps token that it cannot renew, so \ + a longer run would start failing Azure DevOps reads partway through. Lower \ + timeout-minutes, or split the work across runs." + ) +} + /// Reject explicit Stage 1 read-policy options until the credential-isolated /// proxy enforces them. /// @@ -5421,6 +5461,45 @@ safe-outputs: assert!(error.contains("credential-isolated Azure DevOps proxy")); } + /// The proxy holds one non-renewable bearer, so a run must not be able to + /// outlive it. Enforced at compile time because the alternative is opaque + /// 502s partway through a run. + #[test] + fn proxied_timeout_is_bounded_by_the_token_lifetime() { + let proxied = "---\nname: t\ndescription: d\npermissions:\n read:\n service-connection: sc\n---\n"; + let (fm, _) = parse_markdown(proxied).unwrap(); + + assert!(validate_proxied_timeout(&fm, MAX_PROXIED_TIMEOUT_MINUTES).is_ok()); + + let error = validate_proxied_timeout(&fm, MAX_PROXIED_TIMEOUT_MINUTES + 1) + .unwrap_err() + .to_string(); + assert!( + error.contains("cannot renew"), + "the message must say why, not just that it is too long: {error}" + ); + assert!( + error.contains(&MAX_PROXIED_TIMEOUT_MINUTES.to_string()), + "the message must name the limit: {error}" + ); + } + + /// Workflows that do not use the proxy hold no Azure DevOps credential in + /// the agent, so there is nothing to expire and no reason to bound them. + #[test] + fn unproxied_timeout_is_not_bounded() { + for source in [ + "---\nname: t\ndescription: d\n---\n", + "---\nname: t\ndescription: d\npermissions:\n read: my-read-sc\n---\n", + ] { + let (fm, _) = parse_markdown(source).unwrap(); + assert!( + validate_proxied_timeout(&fm, MAX_PROXIED_TIMEOUT_MINUTES * 10).is_ok(), + "a workflow without the proxy must not be limited: {source}" + ); + } + } + #[test] fn test_validate_front_matter_identity_rejects_macro_in_description() { let mut fm = minimal_front_matter(); From e4cd6c9ce8283d5a3c56a090b1d16ac73ffcc997 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 2 Aug 2026 22:06:33 +0100 Subject: [PATCH 12/42] refactor(ado-proxy): carry interception material as versioned JSON The stdin material used an ad-hoc `### MARKER` format whose parser matched markers anywhere in a line rather than anchored to line starts, so a PEM or token value containing a marker could fabricate a section. Duplicate sections resolved silently to the last occurrence, truncation was only caught when it happened to break PEM shape, and there was no version to reject a future format against. Replace it with a versioned JSON document carrying base64 blobs. Truncation now fails at the JSON parse, values cannot influence framing, unknown schema versions are rejected outright, and every blob is validated for base64 round-trip and PEM shape before use. Each failure names what was wrong. Verified with a host-generated document piped into the real bundle on node:20-slim: the engine starts with no material on any filesystem, and truncated, wrong-schema, missing-token, corrupt-base64 and empty inputs each fail closed with a distinct message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- scripts/ado-script/src/ado-proxy/ca.test.ts | 147 ++++++------ scripts/ado-script/src/ado-proxy/ca.ts | 226 +++++++++++------- .../src/ado-proxy/proxy.e2e.test.ts | 49 ++-- 3 files changed, 255 insertions(+), 167 deletions(-) diff --git a/scripts/ado-script/src/ado-proxy/ca.test.ts b/scripts/ado-script/src/ado-proxy/ca.test.ts index 55792b52e..c4874c97f 100644 --- a/scripts/ado-script/src/ado-proxy/ca.test.ts +++ b/scripts/ado-script/src/ado-proxy/ca.test.ts @@ -2,115 +2,133 @@ * Parser tests for the piped interception material. * * These use synthetic PEM blocks rather than real `openssl` output: the parser - * cares about *structure*, and shape-only fixtures keep the suite fast and - * free of a toolchain dependency. Real material is exercised end to end in + * cares about *structure*, and shape-only fixtures keep the suite fast and free + * of a toolchain dependency. Real material is exercised end to end in * `proxy.e2e.test.ts`. */ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync, openSync, closeSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { CaError, parseCaMaterials, publishCaCertificate, readCaMaterials } from "./ca.js"; +import { + CaError, + MATERIAL_SCHEMA, + parseCaMaterials, + publishCaCertificate, + readCaMaterials, +} from "./ca.js"; const KEY = "-----BEGIN PRIVATE KEY-----\nMIIfake\n-----END PRIVATE KEY-----\n"; const CERT = "-----BEGIN CERTIFICATE-----\nMIIfake\n-----END CERTIFICATE-----\n"; +const TOKEN = "canary-bearer"; -function stream(...sections: string[]): string { - return sections.join(""); -} +const b64 = (value: string): string => Buffer.from(value, "utf8").toString("base64"); -const CA_SECTION = `### CA\n${CERT}`; -const TOKEN_SECTION = "### TOKEN\ncanary-bearer\n"; -const host = (name: string): string => `### HOST ${name}\n${KEY}${CERT}`; +function material(overrides: Record = {}): string { + return JSON.stringify({ + schema: MATERIAL_SCHEMA, + ca_cert: b64(CERT), + token: b64(TOKEN), + leaves: { "dev.azure.com": { key: b64(KEY), cert: b64(CERT) } }, + ...overrides, + }); +} describe("parseCaMaterials", () => { - it("parses a CA and its leaves", () => { - const materials = parseCaMaterials( - stream(CA_SECTION, TOKEN_SECTION, host("dev.azure.com"), host("app.vssps.visualstudio.com")), - ); + it("parses a well-formed document", () => { + const materials = parseCaMaterials(material()); expect(materials.caCertPem).toContain("BEGIN CERTIFICATE"); - expect([...materials.leaves.keys()].sort()).toEqual([ - "app.vssps.visualstudio.com", - "dev.azure.com", - ]); + expect(materials.token).toBe(TOKEN); expect(materials.leaves.get("dev.azure.com")?.key).toContain("BEGIN PRIVATE KEY"); }); it("lowercases hostnames so SNI lookup cannot miss on case", () => { - const materials = parseCaMaterials(stream(CA_SECTION, TOKEN_SECTION, host("DEV.Azure.COM"))); + const materials = parseCaMaterials( + material({ leaves: { "DEV.Azure.COM": { key: b64(KEY), cert: b64(CERT) } } }), + ); expect(materials.leaves.has("dev.azure.com")).toBe(true); }); it("rejects an empty stream", () => { // The likeliest real failure: the container was started without the pipe. - expect(() => parseCaMaterials("")).toThrow(/no certificate material on stdin/); + expect(() => parseCaMaterials("")).toThrow(/no material on stdin/); expect(() => parseCaMaterials(" \n ")).toThrow(CaError); }); - it("rejects a stream with no CA", () => { - expect(() => parseCaMaterials(stream(host("dev.azure.com")))).toThrow(/no CA section/); + it("rejects a truncated document loudly", () => { + // The previous marker-based format could accept a partial stream; JSON + // cannot, which is the main reason for the change. + expect(() => parseCaMaterials(material().slice(0, 80))).toThrow(/not valid JSON/); }); - it("rejects a stream with no leaves", () => { - // Without a leaf there is nothing to serve, so every intercepted request - // would fail at handshake time with no clue as to why. - expect(() => parseCaMaterials(stream(CA_SECTION, TOKEN_SECTION))).toThrow( - /no host leaves/, + it("rejects a schema it does not implement", () => { + // Producer and consumer are generated and shipped together; a mismatch + // means one of them is stale, which must not silently under-enforce. + expect(() => parseCaMaterials(material({ schema: "ado-aw/other/v9" }))).toThrow( + /does not match/, ); + expect(() => parseCaMaterials(material({ schema: undefined }))).toThrow(/does not match/); }); - it("rejects a stream with no bearer", () => { - // Certificates without a credential would mean every allowed request is - // forwarded unauthenticated, and Azure DevOps answers those with a sign-in - // page a client can mistake for data. - expect(() => parseCaMaterials(stream(CA_SECTION, host("dev.azure.com")))).toThrow( - /no Azure DevOps bearer/, - ); + it("rejects a non-object document", () => { + expect(() => parseCaMaterials("[]")).toThrow(/must be a JSON object/); + expect(() => parseCaMaterials("null")).toThrow(/must be a JSON object/); + expect(() => parseCaMaterials('"a string"')).toThrow(/must be a JSON object/); }); - it("rejects an empty bearer section", () => { - expect(() => - parseCaMaterials(stream(CA_SECTION, "### TOKEN\n \n", host("dev.azure.com"))), - ).toThrow(/no Azure DevOps bearer/); + it("rejects a missing or empty bearer", () => { + expect(() => parseCaMaterials(material({ token: undefined }))).toThrow(/token/); + expect(() => parseCaMaterials(material({ token: "" }))).toThrow(/token/); + expect(() => parseCaMaterials(material({ token: b64(" ") }))).toThrow(/token/); }); - it("carries the bearer through", () => { - const materials = parseCaMaterials( - stream(CA_SECTION, TOKEN_SECTION, host("dev.azure.com")), + it("rejects a document with no leaves", () => { + // Without a leaf there is nothing to serve, so every intercepted request + // would fail at handshake time with no clue as to why. + expect(() => parseCaMaterials(material({ leaves: {} }))).toThrow(/no host leaves/); + expect(() => parseCaMaterials(material({ leaves: undefined }))).toThrow( + /must be a JSON object/, ); - expect(materials.token).toBe("canary-bearer"); }); it("rejects a half-formed leaf rather than serving it", () => { expect(() => - parseCaMaterials(stream(CA_SECTION, `### HOST dev.azure.com\n${CERT}`)), - ).toThrow(/missing its key/); + parseCaMaterials(material({ leaves: { "dev.azure.com": { cert: b64(CERT) } } })), + ).toThrow(/key must be a non-empty base64 string/); expect(() => - parseCaMaterials(stream(CA_SECTION, `### HOST dev.azure.com\n${KEY}`)), - ).toThrow(/missing its certificate/); + parseCaMaterials(material({ leaves: { "dev.azure.com": { key: b64(KEY) } } })), + ).toThrow(/cert must be a non-empty base64 string/); }); - it("rejects a CA section carrying no certificate", () => { - expect(() => parseCaMaterials(stream("### CA\n(nothing)\n", host("h")))).toThrow( - /CA section carried no certificate/, + it("rejects a blob that is not really base64", () => { + // Node's decoder silently drops invalid characters, so without the + // round-trip check a corrupted blob would decode to wrong-but-plausible + // bytes. + expect(() => parseCaMaterials(material({ ca_cert: "not!valid!base64!" }))).toThrow( + /not valid base64/, ); }); - it("rejects a host section with no hostname", () => { - expect(() => parseCaMaterials(stream(CA_SECTION, `### HOST \n${KEY}${CERT}`))).toThrow( - /no hostname/, + it("rejects base64 that decodes to something other than the expected PEM", () => { + expect(() => parseCaMaterials(material({ ca_cert: b64("hello") }))).toThrow( + /expected PEM block/, ); + expect(() => + parseCaMaterials(material({ leaves: { h: { key: b64(CERT), cert: b64(CERT) } } })), + ).toThrow(/key does not contain the expected PEM block/); }); - it("ignores unrecognised sections rather than failing", () => { - // Forward compatibility: a generator adding a section this build does not - // know about must not take the proxy down. + it("cannot be tricked into fabricating a section from a value", () => { + // The defect that motivated the format change: the old marker parser split + // on "### " anywhere in the stream, so a value containing the marker text + // produced a phantom host. JSON has no such ambiguity. const materials = parseCaMaterials( - stream(CA_SECTION, TOKEN_SECTION, "### FUTURE thing\nwhatever\n", host("dev.azure.com")), + material({ token: b64('### HOST evil\n-----BEGIN PRIVATE KEY-----') }), ); - expect(materials.leaves.size).toBe(1); + expect([...materials.leaves.keys()]).toEqual(["dev.azure.com"]); + expect(materials.token).toContain("### HOST evil"); }); }); @@ -126,15 +144,12 @@ describe("publishCaCertificate", () => { }); it("writes the public certificate", () => { - const path = join(directory, "ca.pem"); - publishCaCertificate(path, CERT); - expect(readCaMaterials).toBeTypeOf("function"); - expect(() => publishCaCertificate(path, CERT)).not.toThrow(); + expect(() => publishCaCertificate(join(directory, "ca.pem"), CERT)).not.toThrow(); }); it("refuses to publish anything containing a private key", () => { - // This path is mounted into the MCP container; a key reaching it would - // hand out the ability to impersonate any protected host. + // This path is mounted into the MCP container; a key reaching it would hand + // out the ability to impersonate any protected host. expect(() => publishCaCertificate(join(directory, "ca.pem"), `${CERT}${KEY}`)).toThrow( /private key/, ); @@ -153,10 +168,8 @@ describe("readCaMaterials", () => { }); it("reads and parses from a descriptor", () => { - const path = join(directory, "material.pem"); - writeFileSync(path, stream(CA_SECTION, TOKEN_SECTION, host("dev.azure.com"))); - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { openSync, closeSync } = require("node:fs") as typeof import("node:fs"); + const path = join(directory, "material.json"); + writeFileSync(path, material()); const fd = openSync(path, "r"); try { expect(readCaMaterials(fd).leaves.has("dev.azure.com")).toBe(true); diff --git a/scripts/ado-script/src/ado-proxy/ca.ts b/scripts/ado-script/src/ado-proxy/ca.ts index b5524a380..73c14305d 100644 --- a/scripts/ado-script/src/ado-proxy/ca.ts +++ b/scripts/ado-script/src/ado-proxy/ca.ts @@ -1,30 +1,59 @@ /** - * Interception certificate material, supplied on stdin. + * Interception certificates and the Azure DevOps bearer, supplied on stdin. * * The engine does **not** mint its own certificates. A host pipeline step runs * `openssl` — already an unconditional dependency of every compiled pipeline, - * which mints the MCPG API key with `openssl rand` — and pipes the CA plus one - * leaf per protected host straight into `docker run -i`. Two consequences: + * which mints the MCPG API key with `openssl rand` — and pipes the CA, the + * per-host leaves, and the bearer straight into `docker run -i`. Two + * consequences: * - * - **The private keys touch no filesystem.** Not the runner's, not the - * container's. AWF's chroot makes the agent's root the host's `/host` bind - * mount, so the agent's `/tmp` *is* the runner's `/tmp`; a key written to a - * host path would be agent-readable. Keeping it on stdin sidesteps that - * rather than relying on deleting it in time. There is no exposure window - * to get wrong either, because the engine starts before AWF — at generation - * time no agent exists at all. + * - **No private key or credential touches a filesystem.** Not the runner's, + * not the container's. AWF's chroot makes the agent's root the host's + * `/host` bind mount, so the agent's `/tmp` *is* the runner's `/tmp`; + * anything written to a runner path would be agent-readable. Keeping the + * material on stdin sidesteps that rather than relying on deleting it in + * time. There is no exposure window either, because the engine starts + * before AWF — at generation time no agent exists at all. * - **The engine needs no `openssl`,** so it runs on `node:20-slim` (which * has none) rather than the full `node:20`. That is already the image the * Azure DevOps MCP uses, so it adds nothing to mirror. * - * The protected host set is compiler-known, so every leaf can be generated - * ahead of time. Nothing here has to *issue* a certificate — fortunate, since - * Node can parse X.509 but not issue it. + * The protected host set is compiler-known, so every leaf is generated ahead of + * time. Nothing here has to *issue* a certificate — fortunate, since Node can + * parse X.509 but not issue it. + * + * ## Wire format + * + * A single JSON document, mirroring how MCPG already receives its config + * (`echo "$MCPG_CONFIG" | docker run -i …`): + * + * ```json + * { + * "schema": "ado-aw/ado-proxy-material/v1", + * "ca_cert": "", + * "token": "", + * "leaves": { "dev.azure.com": { "key": "", "cert": "" } } + * } + * ``` + * + * Blobs are base64 so the generating shell never has to escape newlines, and so + * a corrupted blob fails at decode rather than yielding a subtly wrong + * certificate. `JSON.parse` supplies the structural validation: a truncated + * stream fails loudly, no value can fabricate a section, and `schema` fails + * closed if producer and consumer ever diverge. + * + * An earlier revision used an ad-hoc `### MARKER` format. It was replaced + * because marker matching was not anchored to line starts — a value containing + * the marker text could fabricate a section — and duplicate sections resolved + * silently to the last occurrence. */ import { readFileSync, writeFileSync } from "node:fs"; export class CaError extends Error {} +/** Wire-format version, checked on parse so a mismatch fails closed. */ +export const MATERIAL_SCHEMA = "ado-aw/ado-proxy-material/v1"; + /** A leaf certificate and its key, for one protected host. */ export interface Leaf { readonly key: string; @@ -40,99 +69,128 @@ export interface CaMaterials { /** * The Azure DevOps bearer. * - * Carried in the same stream as the certificates because it has the same + * Carried in the same document as the certificates because it has the same * custody requirement: it must reach this process without touching a path * the agent can read. */ readonly token: string; } +const PRIVATE_KEY = + /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC )?PRIVATE KEY-----/; +const CERTIFICATE = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/; + +function asRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new CaError(`${label} must be a JSON object`); + } + return value as Record; +} + +/** Strip base64 padding so a round-trip comparison is not defeated by it. */ +function withoutPadding(value: string): string { + return value.replace(/=+$/, ""); +} + /** - * Section markers in the piped stream. + * Decode one base64 field. * - * A marker format rather than bare PEM concatenation, because each leaf's key - * and certificate must stay associated with *its* hostname — relying on order - * alone would be a silent correctness trap if the generator ever changed. + * The encoding is verified by re-encoding rather than trusted: Node's decoder + * is lenient and silently drops invalid characters, so a corrupted blob would + * otherwise decode to plausible-looking but wrong bytes. */ -const CA_MARKER = "### CA"; -const HOST_MARKER = "### HOST "; -const TOKEN_MARKER = "### TOKEN"; - +function decodeBase64(source: Record, key: string, label: string): string { + const value = source[key]; + if (typeof value !== "string" || value.trim() === "") { + throw new CaError(`${label} must be a non-empty base64 string`); + } + const normalized = value.replace(/\s+/g, ""); + const decoded = Buffer.from(normalized, "base64"); + if (withoutPadding(decoded.toString("base64")) !== withoutPadding(normalized)) { + throw new CaError(`${label} is not valid base64`); + } + const text = decoded.toString("utf8"); + if (text.trim() === "") { + throw new CaError(`${label} decoded to nothing`); + } + return text; +} -const PRIVATE_KEY = - /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC )?PRIVATE KEY-----/; -const CERTIFICATE = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/; +function requirePem(text: string, pattern: RegExp, label: string): string { + const match = pattern.exec(text)?.[0]; + if (match === undefined) { + throw new CaError(`${label} does not contain the expected PEM block`); + } + return match; +} /** - * Parse the certificate stream. + * Parse the material document. * - * Fails closed on anything incomplete: a missing CA, a host section without - * both a key and a certificate, or a stream carrying no hosts at all. Each - * would otherwise surface as an opaque TLS handshake failure on the first - * intercepted request, long after the cause. + * Fails closed on anything incomplete or unrecognised: a wrong schema, a + * missing CA, a host without both a key and a certificate, no hosts at all, or + * a missing bearer. Each would otherwise surface as an opaque TLS handshake + * failure or an unauthenticated forward, long after the cause. */ export function parseCaMaterials(raw: string): CaMaterials { if (raw.trim() === "") { throw new CaError( - "no certificate material on stdin; the host generation step must pipe the " + - "CA and leaves into this container", + "no material on stdin; the host generation step must pipe the certificates " + + "and bearer into this container", ); } - const sections = raw.split("### ").slice(1); - let caCertPem: string | undefined; - let token: string | undefined; - const leaves = new Map(); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + // A truncated stream lands here, which is the point: the previous + // marker-based format could accept a partial document. + throw new CaError(`material is not valid JSON: ${(error as Error).message}`); + } + + const document = asRecord(parsed, "material"); - for (const section of sections) { - const body = `### ${section}`; - - if (body.startsWith(CA_MARKER)) { - const cert = CERTIFICATE.exec(body)?.[0]; - if (cert === undefined) throw new CaError("CA section carried no certificate"); - caCertPem = cert; - continue; - } - - if (body.startsWith(TOKEN_MARKER)) { - const newline = body.indexOf("\n"); - token = newline === -1 ? "" : body.slice(newline + 1).trim(); - continue; - } - - if (!body.startsWith(HOST_MARKER)) continue; - - - const newline = body.indexOf("\n"); - const host = body - .slice(HOST_MARKER.length, newline === -1 ? undefined : newline) - .trim() - .toLowerCase(); - if (host === "") throw new CaError("host section carried no hostname"); - - const key = PRIVATE_KEY.exec(body)?.[0]; - const cert = CERTIFICATE.exec(body)?.[0]; - if (key === undefined || cert === undefined) { - // Half a leaf is worse than none: TLS would fail at handshake time with - // nothing to indicate the *material* was the problem. - throw new CaError( - `leaf for ${host} is missing its ${key === undefined ? "key" : "certificate"}`, - ); - } - leaves.set(host, { key, cert }); + if (document.schema !== MATERIAL_SCHEMA) { + throw new CaError( + `material schema ${JSON.stringify(document.schema)} does not match this ` + + `bundle's ${JSON.stringify(MATERIAL_SCHEMA)}; refusing to start`, + ); } - if (caCertPem === undefined) { - throw new CaError("certificate stream carried no CA section"); + const caCertPem = requirePem( + decodeBase64(document, "ca_cert", "material.ca_cert"), + CERTIFICATE, + "material.ca_cert", + ); + + // Starting without a bearer would mean every allowed request is forwarded + // unauthenticated, and Azure DevOps answers those with a sign-in page a + // client can mistake for data. + const token = decodeBase64(document, "token", "material.token").trim(); + + const leavesDocument = asRecord(document.leaves, "material.leaves"); + const leaves = new Map(); + for (const [rawHost, value] of Object.entries(leavesDocument)) { + const host = rawHost.trim().toLowerCase(); + if (host === "") throw new CaError("material.leaves has an empty hostname"); + const leaf = asRecord(value, `material.leaves[${host}]`); + leaves.set(host, { + key: requirePem( + decodeBase64(leaf, "key", `material.leaves[${host}].key`), + PRIVATE_KEY, + `material.leaves[${host}].key`, + ), + cert: requirePem( + decodeBase64(leaf, "cert", `material.leaves[${host}].cert`), + CERTIFICATE, + `material.leaves[${host}].cert`, + ), + }); } + if (leaves.size === 0) { - throw new CaError("certificate stream carried no host leaves"); - } - if (token === undefined || token === "") { - // Starting without a bearer would mean every allowed request is forwarded - // unauthenticated, and Azure DevOps answers those with a sign-in page a - // client can mistake for data. Refuse instead. - throw new CaError("stream carried no Azure DevOps bearer"); + throw new CaError("material carried no host leaves"); } return { caCertPem, leaves, token }; @@ -151,7 +209,7 @@ export function readCaMaterials(fd: number = 0): CaMaterials { try { raw = readFileSync(fd, "utf8"); } catch (error) { - throw new CaError(`cannot read certificate material: ${(error as Error).message}`); + throw new CaError(`cannot read material: ${(error as Error).message}`); } return parseCaMaterials(raw); } @@ -159,8 +217,8 @@ export function readCaMaterials(fd: number = 0): CaMaterials { /** * Publish the CA certificate where the MCP container can mount it. * - * Only the public certificate is ever written out; the private keys stay in - * this process. + * Only the public certificate is ever written out; the private keys and the + * bearer stay in this process. */ export function publishCaCertificate(path: string, caCertPem: string): void { if (PRIVATE_KEY.test(caCertPem)) { diff --git a/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts index ede9acd32..60e256722 100644 --- a/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts +++ b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts @@ -31,7 +31,7 @@ import { import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { parseCaMaterials, type CaMaterials } from "./ca.js"; +import { MATERIAL_SCHEMA, parseCaMaterials, type CaMaterials } from "./ca.js"; import { CATALOG_SCHEMA_VERSION } from "./catalog.js"; import type { ProxyConfig, ProxyPolicy } from "./config.js"; import { DecisionLog } from "./log.js"; @@ -97,7 +97,7 @@ interface Harness { readonly proxyCaPem: string; readonly upstreamCalls: UpstreamCall[]; readonly tunnelTargets: string[]; - readonly materialStream: string; + readonly materialDocument: string; } let workdir: string; @@ -115,15 +115,16 @@ function listen(server: { listen: (...args: never[]) => void }): Promise } /** - * Mint CA material in the stream format the engine consumes. + * Mint certificate material in the document format the engine consumes. * * The engine no longer mints its own certificates — a host pipeline step does, * and pipes them in — so this stands in for that step. It returns the parsed - * form for the harness's own servers, and the raw stream for feeding the engine. + * form for the harness's own servers, and the raw document for feeding the + * engine. */ function mintForTest(directory: string, hosts: readonly string[]): { materials: CaMaterials; - stream: string; + document: string; } { mkdirSync(directory, { recursive: true }); const run = (args: readonly string[]): void => { @@ -132,6 +133,8 @@ function mintForTest(directory: string, hosts: readonly string[]): { stdio: ["ignore", "ignore", "pipe"], }); }; + const b64 = (path: string): string => + Buffer.from(readFileSync(join(directory, path), "utf8"), "utf8").toString("base64"); run([ "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", @@ -139,8 +142,7 @@ function mintForTest(directory: string, hosts: readonly string[]): { "-addext", "basicConstraints=critical,CA:TRUE,pathlen:0", ]); - let stream = `### CA\n${readFileSync(join(directory, "ca.pem"), "utf8")}`; - + const leaves: Record = {}; for (const host of hosts) { writeFileSync(join(directory, "leaf.ext"), "basicConstraints=CA:FALSE\n" + @@ -151,12 +153,17 @@ function mintForTest(directory: string, hosts: readonly string[]): { "-keyout", "leaf.key", "-out", "leaf.csr"]); run(["x509", "-req", "-in", "leaf.csr", "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial", "-days", "2", "-extfile", "leaf.ext", "-out", "leaf.pem"]); - stream += `### HOST ${host}\n` + - readFileSync(join(directory, "leaf.key"), "utf8") + - readFileSync(join(directory, "leaf.pem"), "utf8"); + leaves[host] = { key: b64("leaf.key"), cert: b64("leaf.pem") }; } - return { materials: parseCaMaterials(`${stream}### TOKEN\n${CANARY}\n`), stream }; + const document = JSON.stringify({ + schema: MATERIAL_SCHEMA, + ca_cert: b64("ca.pem"), + token: Buffer.from(CANARY, "utf8").toString("base64"), + leaves, + }); + + return { materials: parseCaMaterials(document), document }; } /** A TLS server standing in for `dev.azure.com`. */ @@ -421,7 +428,7 @@ beforeAll(async () => { const plainCa = mintForTest(join(workdir, "plain-ca"), ["example.test"]).materials; const proxyMaterial = mintForTest(join(workdir, "proxy-ca"), POLICY.protected_hosts); const proxyCa = proxyMaterial.materials; - const proxyCaStream = `${proxyMaterial.stream}### TOKEN\n${CANARY}\n`; + const proxyCaDocument = proxyMaterial.document; const upstreamCalls: UpstreamCall[] = []; const tunnelTargets: string[] = []; @@ -475,7 +482,7 @@ beforeAll(async () => { proxyCaPem: proxyCa.caCertPem, upstreamCalls, tunnelTargets, - materialStream: proxyCaStream, + materialDocument: proxyCaDocument, }; // Keep the plain CA reachable for the tunnel assertion. @@ -695,11 +702,21 @@ suite("ado-proxy end to end", () => { }); it("refuses to start without a bearer, rather than forwarding unauthenticated", () => { - // A stream carrying certificates but no token must not yield a running + // A document carrying certificates but no token must not yield a running // proxy: Azure DevOps answers an unauthenticated request with a sign-in // page, which a client can mistake for data. - const withoutToken = harness.materialStream.replace(/### TOKEN\n[\s\S]*$/, ""); - expect(() => parseCaMaterials(withoutToken)).toThrow(/no Azure DevOps bearer/); + const parsed = JSON.parse(harness.materialDocument) as Record; + delete parsed.token; + expect(() => parseCaMaterials(JSON.stringify(parsed))).toThrow(/token/); + }); + + it("cannot have a section fabricated by a value it carries", () => { + // Regression for the format this replaced: a marker-delimited stream let a + // value containing the marker text invent a host section. + const parsed = JSON.parse(harness.materialDocument) as Record; + parsed.token = Buffer.from("### HOST evil\nx", "utf8").toString("base64"); + const materials = parseCaMaterials(JSON.stringify(parsed)); + expect([...materials.leaves.keys()]).not.toContain("evil"); }); it("keeps the bearer out of argv and the environment", () => { From 1ea098c1d130f8893e531b22989d007e13c55c1b Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 2 Aug 2026 23:14:09 +0100 Subject: [PATCH 13/42] feat(ado-proxy): add the policy-engine container lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Agent-job steps that start and stop `ado-proxy`, plus the policy document the engine reads at startup. Nothing emits them yet — the Agent job gains them in the topology-attach change — so this is inert on compiled output. The engine needs no image of its own. It ships as an ado-script bundle that is already downloaded onto the runner, so it is mounted into the same stock Node image the ADO MCP uses; the supply chain is unchanged. Scope is substituted at step time from System.CollectionUri and System.TeamProject rather than baked in at compile time, because a compiled pipeline is routinely queued against a different project than it was compiled in, and a stale scope would silently widen access. The credential and the CA signing key are generated into the agent work directory, never /tmp: AWF mounts /tmp into the agent chroot, so anything written there is readable by the very agent this design withholds the credential from. The material is streamed to the container on stdin — not via -e, which would expose it to `docker inspect`, nor via argv, which would expose it in the process table — and every private key is shredded once handed over. Verified by running the emitted bash against a stubbed docker, then feeding its real output to a live container: the engine came up on both ingresses, published its interception CA, and policed traffic from a container redirected at it with --add-host — allowed discovery reached the Squid egress path, a route outside the granted capabilities got 403 unknown-route, and /_apis/distributedtask/variablegroups got 403 as an always-denied family. No private key survived the step. Two corrections fell out of that run and are recorded in the design doc: --public-ca-file is an output path, not a trust store, and node:20-slim ships no OS trust store at all (Node's own bundled roots verify the upstream). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 5 + src/ado_proxy/mod.rs | 1 + src/ado_proxy/policy.rs | 172 ++++++++++++++ src/compile/ado_bundle.rs | 19 +- src/compile/agentic_pipeline.rs | 343 ++++++++++++++++++++++++++- src/compile/common.rs | 41 ++++ src/compile/extensions/ado_script.rs | 9 + 7 files changed, 587 insertions(+), 3 deletions(-) create mode 100644 src/ado_proxy/policy.rs diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index 0945fde6f..6954e8a5e 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -434,6 +434,11 @@ re-derived. | **The MCP's startup tenant lookup is non-fatal** | In that run `org-tenants.js` failed its `fetchTenantFromApi` call (`TypeError: fetch failed`) and the server logged the error and carried on serving. It targets `vssps.dev.azure.com`, which is *not* in the protected set, so under interception it will fail the same way rather than blocking startup | | Upstream verification is real | The engine refused a self-signed upstream with `unable to verify the first certificate` | | Denials surface usefully to clients | `az` printed the engine's `WrappedException` message verbatim | +| **The emitted start step provisions the engine end to end** | The compiler-generated bash was run with a stubbed `docker`: it substituted the scope from `System.CollectionUri` / `System.TeamProject` (`org=contoso project=Widgets`), minted the CA and a leaf per catalogued protected host (`CN=dev.azure.com`, `SAN=DNS:dev.azure.com`), and assembled a valid `ado-aw/ado-proxy-material/v1` document whose token round-tripped | +| **No private key survives the step** | After the run, the work directory held only certificates, CSRs and the policy — every `.key`, including the CA signing key, had been shredded | +| **The real container starts from exactly that document** | Piping the captured material into `node:20-slim` with the generated policy mounted brought the engine up on both ingresses (`0.0.0.0:11080` proxy, `0.0.0.0:443` direct TLS) and it published its interception CA to the shared host directory | +| **The engine polices live traffic through `--add-host`** | A separate container redirected at the engine's IP, trusting only the published CA with verification on, got: allowed discovery → `502 upstream-failed` (policy allowed; egress attempted **only** via the configured Squid, absent locally); a `repos` route outside the granted capabilities → `403 unknown-route`; `/_apis/distributedtask/variablegroups` → `403` always-denied route family. No denial reached an upstream | +| `--public-ca-file` is an **output**, not a trust store | It is where the engine *writes* its interception CA for clients. Pointing it at `/etc/ssl/certs/ca-certificates.crt` failed `EROFS`. Upstream verification instead uses Node's bundled roots — `node:20-slim` ships **no** OS trust store but carries 144 roots — so nothing needs mounting for it | Three harnesses produce this evidence and should become conformance tests: diff --git a/src/ado_proxy/mod.rs b/src/ado_proxy/mod.rs index 28a3d121e..b84c863f3 100644 --- a/src/ado_proxy/mod.rs +++ b/src/ado_proxy/mod.rs @@ -35,3 +35,4 @@ //! pattern used by the gate evaluator. pub mod catalog; +pub mod policy; diff --git a/src/ado_proxy/policy.rs b/src/ado_proxy/policy.rs new file mode 100644 index 000000000..1a7388e86 --- /dev/null +++ b/src/ado_proxy/policy.rs @@ -0,0 +1,172 @@ +//! Policy document emitted by the compiler and consumed by the `ado-proxy` +//! bundle at startup. +//! +//! The bundle refuses to start on a document it does not fully understand: an +//! unknown key, a `catalog_version` that is not this bundle's, or a catalogued +//! protected host that the document omits are all fatal. That is deliberate — +//! each of those would silently *under*-enforce. Emitting the document from +//! the same Rust module that owns the catalog is what keeps the two in step. +//! +//! Scope values are left as placeholders rather than baked in at compile time. +//! The organization and project are properties of the *run*, not of the +//! workflow source, and a compiled pipeline is routinely queued against a +//! different project than the one it was compiled in. Substituting them at +//! step time (the same `sed` pattern MCPG already uses) keeps the emitted YAML +//! portable and prevents a stale scope from silently widening access. + +use serde::Serialize; + +use super::catalog::{ + CATALOG_SCHEMA_VERSION, Capability, ORGANIZATION_HOST, SPS_FALLBACK_HOST, +}; + +/// Placeholder substituted with the organization name at step time. +pub const ORGANIZATION_PLACEHOLDER: &str = "${ADO_PROXY_ORGANIZATION}"; + +/// Placeholder substituted with the project name at step time. +pub const PROJECT_PLACEHOLDER: &str = "${ADO_PROXY_PROJECT}"; + +/// The policy document handed to the `ado-proxy` bundle via `--policy-file`. +/// +/// Field names and shape are a contract with `parsePolicy` in +/// `scripts/ado-script/src/ado-proxy/config.ts`; the bundle rejects any key it +/// does not recognize, so adding a field here without adding it there is a +/// startup failure rather than a silent mismatch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PolicyDocument { + pub catalog_version: &'static str, + pub organization: String, + pub project: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub repository: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub repository_id: Option, + pub capabilities: Vec<&'static str>, + pub protected_hosts: Vec<&'static str>, +} + +impl PolicyDocument { + /// Build the document for a set of author-requested capabilities. + /// + /// Always-on capabilities are added regardless of what the author asked + /// for, and the result is emitted in [`Capability::ALL`] order so the + /// document is stable no matter how the front matter was written — an + /// author reordering their `capabilities:` list must not produce a + /// different pipeline. + pub fn new(requested: &[Capability]) -> Self { + let capabilities = Capability::ALL + .iter() + .filter(|capability| { + capability.is_always_on() || requested.contains(capability) + }) + .map(|capability| capability.as_str()) + .collect(); + + Self { + catalog_version: CATALOG_SCHEMA_VERSION, + organization: ORGANIZATION_PLACEHOLDER.to_string(), + project: PROJECT_PLACEHOLDER.to_string(), + project_id: None, + repository: None, + repository_id: None, + capabilities, + // Every catalogued host must appear: one the bundle policed but + // the document omitted would be byte-tunnelled to Squid instead, + // which is the single failure mode the proxy cannot tolerate. + protected_hosts: vec![ORGANIZATION_HOST, SPS_FALLBACK_HOST], + } + } + + /// Render as the JSON the bundle reads from `--policy-file`. + pub fn to_json(&self) -> String { + serde_json::to_string_pretty(self) + .expect("PolicyDocument is a plain serializable struct") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discovery_is_present_even_when_unrequested() { + let document = PolicyDocument::new(&[Capability::Repos]); + assert!( + document.capabilities.contains(&"discovery"), + "discovery is always on; without it no supported client can \ + complete its initial resource-area lookup: {:?}", + document.capabilities + ); + } + + #[test] + fn capability_order_is_independent_of_request_order() { + let one = PolicyDocument::new(&[Capability::Boards, Capability::Core]); + let two = PolicyDocument::new(&[Capability::Core, Capability::Boards]); + assert_eq!( + one.capabilities, two.capabilities, + "author-visible ordering must not change the compiled pipeline" + ); + } + + #[test] + fn unrequested_capabilities_are_absent() { + let document = PolicyDocument::new(&[]); + for capability in Capability::ALL { + if capability.is_always_on() { + continue; + } + assert!( + !document.capabilities.contains(&capability.as_str()), + "{} was never requested and must not be granted", + capability.as_str() + ); + } + } + + #[test] + fn every_catalogued_protected_host_is_declared() { + let document = PolicyDocument::new(&[]); + for host in super::super::catalog::catalog().protected_hosts { + assert!( + document.protected_hosts.contains(host), + "{host} is catalogued as protected but absent from the policy; \ + the bundle would byte-tunnel it to Squid unpoliced" + ); + } + } + + #[test] + fn catalog_version_matches_the_catalog() { + let document = PolicyDocument::new(&[]); + assert_eq!( + document.catalog_version, + catalog_version_from_catalog(), + "a policy naming a different catalog version is refused at startup" + ); + } + + fn catalog_version_from_catalog() -> &'static str { + super::super::catalog::catalog().schema_version + } + + #[test] + fn scope_is_left_as_placeholders_for_step_time_substitution() { + let document = PolicyDocument::new(&[]); + assert_eq!(document.organization, ORGANIZATION_PLACEHOLDER); + assert_eq!(document.project, PROJECT_PLACEHOLDER); + } + + #[test] + fn json_omits_unset_optional_scope_fields() { + // The bundle rejects unknown keys, and treats a present-but-null + // narrowing field differently from an absent one. Emitting `null` + // would be a startup failure. + let json = PolicyDocument::new(&[]).to_json(); + assert!(!json.contains("null"), "unset scope fields must be omitted: {json}"); + assert!(!json.contains("project_id")); + assert!(!json.contains("repository")); + } +} diff --git a/src/compile/ado_bundle.rs b/src/compile/ado_bundle.rs index aea2ae6aa..672e1a0f2 100644 --- a/src/compile/ado_bundle.rs +++ b/src/compile/ado_bundle.rs @@ -63,6 +63,17 @@ pub enum Bundle { /// containerized SafeOutputs MCP server can compute a diff base on /// shallow-default pools. PreparePrBase, + /// Credential-isolated Azure DevOps policy engine. Unlike every other + /// bundle it is not invoked by a pipeline step: it is bind-mounted into + /// the `ado-proxy` container and run there, for the whole lifetime of the + /// Agent job. + /// + /// Its auth is [`BundleAuth::None`] despite being the one bundle that + /// holds an ADO bearer. The bearer is *not* an env var: it arrives inside + /// the interception-material document on stdin, so that neither the + /// process table, the container's `Env`, nor any file can expose it. See + /// `scripts/ado-script/src/ado-proxy/ca.ts`. + AdoProxy, } /// The auth contract a bundle requires from the step that invokes it. @@ -142,6 +153,7 @@ impl Bundle { Bundle::Conclusion, Bundle::GithubAppToken, Bundle::PreparePrBase, + Bundle::AdoProxy, ]; /// The bundle's unpacked on-disk path inside the runtime VM. The Conclusion @@ -168,6 +180,7 @@ impl Bundle { Bundle::Conclusion => paths::CONCLUSION_PATH, Bundle::GithubAppToken => paths::GITHUB_APP_TOKEN_PATH, Bundle::PreparePrBase => paths::PREPARE_PR_BASE_PATH, + Bundle::AdoProxy => paths::ADO_PROXY_PATH, } } @@ -194,7 +207,11 @@ impl Bundle { | Bundle::ApprovalSummary // Authenticates to the GitHub API with its own App JWT / minted // token, not the ADO bearer. - | Bundle::GithubAppToken => BundleAuth::None, + | Bundle::GithubAppToken + // Receives its ADO bearer inside the stdin material document, not + // from the environment — deliberately, so the credential is not + // visible in the container's `Env` or the process table. + | Bundle::AdoProxy => BundleAuth::None, } } } diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index fc8d8d9e2..9b4dc85a6 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -65,9 +65,13 @@ use std::path::Path; use super::common::PerJobPools; use super::common::{ - self, ADO_BUILD_ID_SUFFIX, AWF_VERSION, HEADER_MARKER, MCPG_CONTAINER_NAME, MCPG_DOMAIN, - MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, image_ref, + self, ADO_BUILD_ID_SUFFIX, ADO_PROXY_CONTAINER_NAME, ADO_PROXY_IMAGE, ADO_PROXY_LISTEN_PORT, + ADO_PROXY_TLS_PORT, AWF_SQUID_URL, AWF_VERSION, HEADER_MARKER, MCPG_CONTAINER_NAME, + MCPG_DOMAIN, MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, image_ref, }; +use super::extensions::ado_script as paths; +use crate::ado_proxy::catalog::{self, Capability}; +use crate::ado_proxy::policy::PolicyDocument; use super::extensions::{CompileContext, CompilerExtension, Declarations, Extension, McpgConfig}; use super::ir::condition::{Condition, Expr}; use super::ir::env::EnvValue; @@ -3163,6 +3167,187 @@ fn stop_mcpg_step() -> BashStep { bash("Stop MCPG", script).with_condition(Condition::Always) } +/// Start the `ado-proxy` policy engine as a host container. +/// +/// Mirrors [`start_mcpg_step`]: an ordinary bridge-networked container started +/// before AWF, which AWF then joins to its own network via +/// `--topology-attach`. It must start *before* MCPG, because the ADO MCP is +/// redirected at the proxy's container IP and that IP does not exist until the +/// container does. +/// +/// # Why the interception material never touches disk +/// +/// AWF chroots the agent with `/tmp` mounted at both `/tmp` and `/host/tmp`, +/// so *anything* written under `/tmp` is agent-readable. The CA private key +/// and the ADO bearer are therefore generated into the agent-private work +/// directory, streamed into the container on stdin, and deleted — the +/// container holds them in memory only. Writing them where the bundle could +/// read them from a file would hand the agent the exact credential this whole +/// design exists to withhold. +/// +/// Not yet emitted: see [`stop_ado_proxy_step`]. +#[allow(dead_code)] +fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { + let policy = PolicyDocument::new(capabilities).to_json(); + let hosts = catalog::catalog().protected_hosts; + // Mint one leaf per catalogued protected host. A host without a leaf + // cannot be intercepted, so this list must track the catalog rather than + // be maintained separately. + let leaf_loop = hosts + .iter() + .map(|host| format!("\"{host}\"")) + .collect::>() + .join(" "); + + let script = format!( + "# Start the ado-proxy policy engine.\n\ + #\n\ + # The agent never receives an Azure DevOps credential. This container\n\ + # holds it, and serves only the operations in the versioned catalog.\n\ + set -euo pipefail\n\ + \n\ + # Generate into the agent work directory, NOT /tmp: AWF mounts /tmp\n\ + # into the agent chroot, so /tmp is readable by the agent.\n\ + umask 077\n\ + PROXY_DIR=$(mktemp -d \"$(Agent.TempDirectory)/ado-proxy.XXXXXX\")\n\ + cleanup_material() {{ rm -rf \"$PROXY_DIR\"; }}\n\ + trap cleanup_material EXIT\n\ + \n\ + # Policy document. Non-secret, so it is mounted rather than streamed.\n\ + # Scope is substituted here rather than at compile time so the same\n\ + # compiled pipeline can be queued against a different project.\n\ + ADO_PROXY_COLLECTION=\"$(System.CollectionUri)\"\n\ + ADO_PROXY_ORGANIZATION=$(printf '%s' \"$ADO_PROXY_COLLECTION\" | sed -e 's#/*$##' -e 's#.*/##')\n\ + ADO_PROXY_PROJECT=\"$(System.TeamProject)\"\n\ + mkdir -p \"$PROXY_DIR/policy\"\n\ + cat > \"$PROXY_DIR/policy/policy.json\" <<'ADO_PROXY_POLICY_EOF'\n\ + {policy}\n\ + ADO_PROXY_POLICY_EOF\n\ + sed -i \\\n \ + -e \"s|\\${{ADO_PROXY_ORGANIZATION}}|$ADO_PROXY_ORGANIZATION|g\" \\\n \ + -e \"s|\\${{ADO_PROXY_PROJECT}}|$ADO_PROXY_PROJECT|g\" \\\n \ + \"$PROXY_DIR/policy/policy.json\"\n\ + echo \"ado-proxy policy:\"\n\ + python3 -m json.tool < \"$PROXY_DIR/policy/policy.json\"\n\ + \n\ + # Interception certificate authority and one leaf per protected host.\n\ + openssl req -x509 -newkey rsa:2048 -nodes -days 2 \\\n \ + -subj \"/CN=ado-aw ado-proxy interception CA\" \\\n \ + -keyout \"$PROXY_DIR/ca.key\" -out \"$PROXY_DIR/ca.pem\" \\\n \ + -addext \"basicConstraints=critical,CA:TRUE,pathlen:0\" 2>/dev/null\n\ + for PROXY_HOST in {leaf_loop}; do\n \ + printf 'basicConstraints=CA:FALSE\\nkeyUsage=critical,digitalSignature,keyEncipherment\\nextendedKeyUsage=serverAuth\\nsubjectAltName=DNS:%s\\n' \"$PROXY_HOST\" > \"$PROXY_DIR/leaf.ext\"\n \ + openssl req -new -newkey rsa:2048 -nodes -subj \"/CN=$PROXY_HOST\" \\\n \ + -keyout \"$PROXY_DIR/$PROXY_HOST.key\" -out \"$PROXY_DIR/$PROXY_HOST.csr\" 2>/dev/null\n \ + openssl x509 -req -in \"$PROXY_DIR/$PROXY_HOST.csr\" \\\n \ + -CA \"$PROXY_DIR/ca.pem\" -CAkey \"$PROXY_DIR/ca.key\" -CAcreateserial \\\n \ + -days 2 -extfile \"$PROXY_DIR/leaf.ext\" -out \"$PROXY_DIR/$PROXY_HOST.pem\" 2>/dev/null\n\ + done\n\ + \n\ + # The proxy publishes its own interception CA certificate for clients\n\ + # to trust. It goes under /tmp deliberately: AWF mounts /tmp into the\n\ + # agent chroot, and this is a public certificate that az and the ADO\n\ + # MCP must be able to read. The matching private key never leaves\n\ + # $PROXY_DIR and is destroyed below.\n\ + mkdir -p /tmp/gh-aw/ado-proxy\n\ + echo \"##vso[task.setvariable variable=ADO_PROXY_CA_FILE]/tmp/gh-aw/ado-proxy/ado-proxy-ca.pem\"\n\ + \n\ + # Build the material document. jq assembles it so that a value\n\ + # containing JSON metacharacters cannot alter the document shape.\n\ + PROXY_MATERIAL=$(jq -n \\\n \ + --arg schema 'ado-aw/ado-proxy-material/v1' \\\n \ + --arg ca_cert \"$(base64 -w0 < \"$PROXY_DIR/ca.pem\")\" \\\n \ + --arg token \"$(printf '%s' \"$ADO_PROXY_BEARER\" | base64 -w0)\" \\\n \ + '{{schema: $schema, ca_cert: $ca_cert, token: $token, leaves: {{}}}}')\n\ + for PROXY_HOST in {leaf_loop}; do\n \ + PROXY_MATERIAL=$(printf '%s' \"$PROXY_MATERIAL\" | jq \\\n \ + --arg host \"$PROXY_HOST\" \\\n \ + --arg key \"$(base64 -w0 < \"$PROXY_DIR/$PROXY_HOST.key\")\" \\\n \ + --arg cert \"$(base64 -w0 < \"$PROXY_DIR/$PROXY_HOST.pem\")\" \\\n \ + '.leaves[$host] = {{key: $key, cert: $cert}}')\n\ + done\n\ + \n\ + # Remove any container left behind by an interrupted run.\n\ + docker rm -f {ADO_PROXY_CONTAINER_NAME} 2>/dev/null || true\n\ + mkdir -p /tmp/gh-aw/ado-proxy-logs\n\ + \n\ + printf '%s' \"$PROXY_MATERIAL\" | docker run -i --rm \\\n \ + --name {ADO_PROXY_CONTAINER_NAME} \\\n \ + --network bridge \\\n \ + -v \"{ado_proxy_path}:/app/ado-proxy.js:ro\" \\\n \ + -v \"$PROXY_DIR/policy:/etc/ado-proxy:ro\" \\\n \ + -v /tmp/gh-aw/ado-proxy:/var/lib/ado-proxy \\\n \ + -v /tmp/gh-aw/ado-proxy-logs:/var/log/ado-proxy \\\n \ + {ado_proxy_image} \\\n \ + node /app/ado-proxy.js \\\n \ + --policy-file /etc/ado-proxy/policy.json \\\n \ + --public-ca-file /var/lib/ado-proxy/ado-proxy-ca.pem \\\n \ + --upstream-proxy {squid_url} \\\n \ + --listen-port {listen_port} \\\n \ + --tls-port {tls_port} \\\n \ + --log-dir /var/log/ado-proxy \\\n \ + > /tmp/gh-aw/ado-proxy-logs/stdout.log 2>&1 &\n\ + \n\ + # Drop the private material as soon as it has been handed over. The\n\ + # container has it in memory; nothing else needs it again.\n\ + PROXY_MATERIAL=\"\"\n\ + unset PROXY_MATERIAL\n\ + shred -u \"$PROXY_DIR/ca.key\" \"$PROXY_DIR\"/*.key 2>/dev/null || rm -f \"$PROXY_DIR/ca.key\" \"$PROXY_DIR\"/*.key\n\ + \n\ + # Resolve the container IP so the ADO MCP can be redirected at it.\n\ + PROXY_READY=false\n\ + # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop\n\ + for i in $(seq 1 30); do\n \ + ADO_PROXY_IP=$(docker inspect -f '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}}{{{{end}}}}' {ADO_PROXY_CONTAINER_NAME} 2>/dev/null || true)\n \ + if [ -n \"$ADO_PROXY_IP\" ]; then\n \ + PROXY_READY=true\n \ + break\n \ + fi\n \ + sleep 1\n\ + done\n\ + if [ \"$PROXY_READY\" != \"true\" ]; then\n \ + echo \"ado-proxy log tail:\"\n \ + cat /tmp/gh-aw/ado-proxy-logs/stdout.log 2>/dev/null || true\n \ + echo \"##vso[task.complete result=Failed]ado-proxy did not start within 30s\"\n \ + exit 1\n\ + fi\n\ + echo \"ado-proxy is ready at $ADO_PROXY_IP\"\n\ + echo \"##vso[task.setvariable variable=ADO_PROXY_IP]$ADO_PROXY_IP\"\n", + ado_proxy_path = paths::ADO_PROXY_PATH, + ado_proxy_image = ADO_PROXY_IMAGE, + squid_url = AWF_SQUID_URL, + listen_port = ADO_PROXY_LISTEN_PORT, + tls_port = ADO_PROXY_TLS_PORT, + ); + + bash("Start ado-proxy policy engine", script) + // The bearer is read from the environment here and immediately + // base64-encoded into the stdin document; it is never written to a + // file and never reaches the container's `Env`. + .with_env("ADO_PROXY_BEARER", EnvValue::secret("SC_READ_TOKEN")) +} + +/// Stop the `ado-proxy` container. +/// +/// `--rm` only fires on a clean exit, so an OOM or SIGKILL would otherwise +/// leave the container — and the credential it holds in memory — running past +/// the job. +/// +/// Not yet emitted: the Agent job gains these steps in `proxy-topology-attach`, +/// once AWF is also told to attach the container. Landing the lifecycle first +/// keeps that change to the wiring alone. +#[allow(dead_code)] +fn stop_ado_proxy_step() -> BashStep { + let script = format!( + "# Stop the ado-proxy policy engine\n\ + echo \"Stopping ado-proxy...\"\n\ + docker stop {ADO_PROXY_CONTAINER_NAME} 2>/dev/null || true\n\ + docker rm -f {ADO_PROXY_CONTAINER_NAME} 2>/dev/null || true\n\ + echo \"ado-proxy stopped\"\n" + ); + bash("Stop ado-proxy", script).with_condition(Condition::Always) +} + fn copy_logs_step(engine_log_dir: &str, is_detection: bool) -> BashStep { if is_detection { // Detection job copies its logs into analyzed_outputs/logs (the @@ -3962,6 +4147,160 @@ mod tests { assert!(msg.contains("missing `env:` key"), "got: {msg}"); } + + // ── start_ado_proxy_step / stop_ado_proxy_step ────────────────────────── + + #[test] + fn ado_proxy_never_writes_the_bearer_or_ca_key_under_tmp() { + // AWF chroots the agent with /tmp mounted at both /tmp and /host/tmp, + // so anything the step writes under /tmp is agent-readable. The + // credential this design exists to withhold must not land there. + let script = start_ado_proxy_step(&[]).script; + + assert!( + script.contains("mktemp -d \"$(Agent.TempDirectory)/ado-proxy."), + "private material must be generated outside /tmp: {script}" + ); + for private in ["ca.key", "$ADO_PROXY_BEARER", "PROXY_MATERIAL"] { + for line in script.lines().filter(|line| line.contains(private)) { + assert!( + !line.contains("/tmp/gh-aw") && !line.contains("> /tmp"), + "{private} must never be written under /tmp: {line}" + ); + } + } + } + + #[test] + fn ado_proxy_streams_material_on_stdin_rather_than_via_env_or_argv() { + let step = start_ado_proxy_step(&[]); + + assert!( + step.script.contains("printf '%s' \"$PROXY_MATERIAL\" | docker run -i"), + "material must arrive on stdin: {}", + step.script + ); + // A `-e` would put it in the container's `Env`, readable by anyone who + // can call `docker inspect`; an argv flag would expose it in the + // process table. + assert!( + !step.script.contains("-e ADO_PROXY_BEARER"), + "the bearer must not reach the container environment" + ); + assert!( + !step.script.contains("--token"), + "the bearer must not be passed as an argument" + ); + } + + #[test] + fn ado_proxy_destroys_the_signing_key_after_handover() { + let script = start_ado_proxy_step(&[]).script; + assert!( + script.contains("shred -u \"$PROXY_DIR/ca.key\""), + "the CA signing key must not outlive handover: {script}" + ); + assert!( + script.contains("trap cleanup_material EXIT"), + "the work directory must be removed even on failure: {script}" + ); + } + + #[test] + fn ado_proxy_publishes_only_the_ca_certificate() { + let script = start_ado_proxy_step(&[]).script; + // `--public-ca-file` is an *output*: the proxy writes its interception + // CA there so clients can trust it. It must land somewhere the agent + // can read (AWF mounts /tmp into the chroot) — unlike the signing key. + assert!(script.contains("--public-ca-file /var/lib/ado-proxy/ado-proxy-ca.pem")); + assert!(script.contains("-v /tmp/gh-aw/ado-proxy:/var/lib/ado-proxy")); + assert!( + script.contains( + "##vso[task.setvariable variable=ADO_PROXY_CA_FILE]/tmp/gh-aw/ado-proxy/ado-proxy-ca.pem" + ), + "clients need the published certificate's path: {script}" + ); + assert!( + !script.contains("ado-proxy-ca.key") && !script.contains("public/ca.key"), + "the CA private key must never be published" + ); + } + + #[test] + fn ado_proxy_mints_a_leaf_for_every_catalogued_protected_host() { + let script = start_ado_proxy_step(&[]).script; + for host in catalog::catalog().protected_hosts { + assert!( + script.contains(&format!("\"{host}\"")), + "{host} is catalogued as protected but gets no interception leaf, \ + so it could not be policed: {script}" + ); + } + } + + #[test] + fn ado_proxy_egresses_only_through_squid() { + let script = start_ado_proxy_step(&[]).script; + assert!( + script.contains(&format!("--upstream-proxy {AWF_SQUID_URL}")), + "the only egress must be Squid, so an outage is a 502 not a direct socket" + ); + // Upstream Azure DevOps certificates are verified against Node's own + // bundled roots (measured: 144 in node:20-slim, which ships no OS + // trust store at all). Nothing needs mounting for that, and + // `rejectUnauthorized` is never disabled. + assert!( + !script.contains("NODE_TLS_REJECT_UNAUTHORIZED"), + "upstream verification must never be disabled: {script}" + ); + } + + #[test] + fn ado_proxy_reuses_the_existing_node_image() { + // The proxy ships as an ado-script bundle already downloaded onto the + // runner, so it must not introduce an image to build, pin or mirror. + let script = start_ado_proxy_step(&[]).script; + assert_eq!(ADO_PROXY_IMAGE, common::ADO_MCP_IMAGE); + assert!(script.contains(&format!("{ADO_PROXY_IMAGE} \\"))); + assert!(script.contains(&format!("{}:/app/ado-proxy.js:ro", paths::ADO_PROXY_PATH))); + } + + #[test] + fn ado_proxy_embeds_a_policy_the_bundle_will_accept() { + let script = start_ado_proxy_step(&[Capability::Repos]).script; + assert!(script.contains("\"catalog_version\"")); + assert!( + script.contains("\"discovery\""), + "discovery is always on; without it no client completes startup" + ); + assert!(script.contains("\"repos\"")); + assert!( + !script.contains("\"boards\""), + "an unrequested capability must not be granted" + ); + } + + #[test] + fn ado_proxy_recovers_from_an_interrupted_previous_run() { + // --rm only fires on clean exit; an OOM or SIGKILL leaves the + // container, and with it a live credential, behind. + assert!( + start_ado_proxy_step(&[]) + .script + .contains(&format!("docker rm -f {ADO_PROXY_CONTAINER_NAME}")) + ); + assert!( + stop_ado_proxy_step() + .script + .contains(&format!("docker rm -f {ADO_PROXY_CONTAINER_NAME}")) + ); + } + + #[test] + fn ado_proxy_is_stopped_even_when_the_job_fails() { + assert_eq!(stop_ado_proxy_step().condition, Some(Condition::Always)); + } + // ── start_mcpg_step ───────────────────────────────────────────────────── #[test] diff --git a/src/compile/common.rs b/src/compile/common.rs index 9d40e8435..5a596cf5b 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1607,6 +1607,47 @@ pub const ADO_MCP_PACKAGE: &str = "@azure-devops/mcp"; /// Reserved MCPG server name for the auto-configured ADO MCP. pub const ADO_MCP_SERVER_NAME: &str = "azure-devops"; +/// Stable container name for the `ado-proxy` policy engine. +/// +/// Doubles as the DNS name the agent uses to reach it, matching the MCPG +/// convention, and is the name passed to AWF's `--topology-attach`. +#[allow(dead_code)] +pub const ADO_PROXY_CONTAINER_NAME: &str = "awmg-ado-proxy"; + +/// Base image for the `ado-proxy` container. +/// +/// The proxy ships as an `ado-script` bundle that is already downloaded onto +/// the runner, so it needs no image of its own — it is mounted into the same +/// stock Node image the ADO MCP uses. That keeps the supply chain unchanged: +/// no new image to build, publish, pin, or mirror. +#[allow(dead_code)] +pub const ADO_PROXY_IMAGE: &str = ADO_MCP_IMAGE; + +/// Port `ado-proxy` accepts `CONNECT`-style proxy clients on. +#[allow(dead_code)] +pub const ADO_PROXY_LISTEN_PORT: u16 = 11080; + +/// Port `ado-proxy` terminates direct TLS on. +/// +/// Must be 443: clients redirected with `--add-host` believe they are talking +/// to `dev.azure.com` and will not use a non-default port. +#[allow(dead_code)] +pub const ADO_PROXY_TLS_PORT: u16 = 443; + +/// AWF's Squid proxy, addressed by IP on the AWF network. +/// +/// AWF fixes this address as a constant (`SQUID_IP` in its `constants.ts`, +/// within the `172.30.0.0/24` `awf-net` subnet), so it can be configured +/// before AWF has started rather than discovered afterwards. Addressing it by +/// IP rather than by name also sidesteps the embedded-DNS failures AWF itself +/// works around under gVisor and ARC/DinD. +/// +/// Routing the proxy's own egress through Squid rather than exempting it from +/// the firewall follows AWF's own API-proxy sidecar, which is deliberately +/// given no iptables exemption for the same reason. +#[allow(dead_code)] +pub const AWF_SQUID_URL: &str = "http://172.30.0.10:3128"; + /// Rewrite a GHCR image reference onto an internal registry when configured. /// /// Only the final artifact name is preserved under the configured registry diff --git a/src/compile/extensions/ado_script.rs b/src/compile/extensions/ado_script.rs index bdf5c716c..8f5f1e798 100644 --- a/src/compile/extensions/ado_script.rs +++ b/src/compile/extensions/ado_script.rs @@ -38,6 +38,15 @@ use crate::compile::types::{PipelineFilters, PrFilters, SupplyChainConfig}; pub(crate) 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"; +/// Path to the ado-proxy bundle inside the unpacked `ado-script.zip`. +/// +/// Unlike every other bundle this one is not executed by a pipeline step. It +/// is bind-mounted into the `ado-proxy` container and run there, which is what +/// lets the policy engine ship without an image of its own. The path is +/// agent-readable (AWF mounts `/tmp` into the chroot), which is fine: the +/// bundle is code, and the credential it uses never touches disk — it arrives +/// on stdin. +pub(crate) const ADO_PROXY_PATH: &str = "/tmp/ado-aw-scripts/ado-script/ado-proxy.js"; /// Path to the exec-context-pr bundle inside the unpacked `ado-script.zip`. /// Consumed by `src/compile/extensions/exec_context/pr.rs` to invoke /// the bundle from the PR contributor's prepare step. From bcdf79fa785a9a74a5ae7d193d2fb48a37a1a8e1 Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 3 Aug 2026 10:38:37 +0100 Subject: [PATCH 14/42] feat(ado-proxy): attach the policy engine to the AWF network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gives run_agent_step a second --topology-attach for the policy engine, so the agent can reach it, and adds the engine to the NO_PROXY peer list — its name is not public DNS, and routing it through Squid would break the very connection that reaches the policy. Still passed false at the only call site, so compiled output is byte-identical; no lock file changes. Verified against the pinned AWF v0.27.32 binary rather than the local clone, which is stale at v0.23.1 and predates the flag entirely: - --help documents --topology-attach as "Repeatable", with a two-peer example; - config.topologyAttach is an array, and both connectTopologyContainers and getTopologyContainerIps take the whole list; - patchComposeWithTopologyHosts writes extra_hosts for every peer, which is what will let the az wrapper resolve the engine by name. AWF does this precisely because Docker's embedded DNS is unreliable under gVisor and ARC/DinD. The first attempt emitted ragged 11- and 13-space indents because the preceding --network-isolation continuation already supplies the indent for the first line. Both variants are now shellcheck-clean, and a test asserts that enabling the engine changes nothing in the invocation but the attachment and NO_PROXY lines. Runner verification of two live peers remains outstanding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- src/compile/agentic_pipeline.rs | 118 +++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 9b4dc85a6..f034af4e7 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -1059,6 +1059,10 @@ fn build_agent_job( &cfg.engine_env, &cfg.byom_exclude_keys, front_matter.supply_chain(), + // Not yet enabled anywhere: the policy engine is wired in behind the + // `permissions.read` policy in a later change. Passing `false` keeps + // compiled output byte-identical until then. + false, )?)); // 18a. Revoke the GitHub App token (best-effort, always) once the Copilot @@ -2987,6 +2991,7 @@ fn awf_exclude_env_flags(exclude_keys: &[String]) -> String { block } +#[allow(clippy::too_many_arguments)] fn run_agent_step( allowed_domains: &str, awf_mounts: &str, @@ -2995,6 +3000,7 @@ fn run_agent_step( engine_env: &str, byom_exclude_keys: &[String], supply_chain: Option<&SupplyChainConfig>, + ado_proxy_enabled: bool, ) -> Result { // The awf_mounts string is a `\`-joined chain of `--mount "..."` lines. // Render each at 2-space indent inside the bash body (the surrounding @@ -3011,8 +3017,40 @@ fn run_agent_step( }; let image_flags_block = awf_image_flags(supply_chain); let exclude_env_block = awf_exclude_env_flags(byom_exclude_keys); + + // AWF attaches externally-launched trusted containers to its internal + // network by name. The flag is repeatable (verified against the pinned + // v0.27.32 binary: "Repeatable. Example: --topology-attach mcp-gateway + // --topology-attach difc-proxy"), which is what lets the policy engine + // join alongside MCPG. + // + // Attaching also gives the agent an `/etc/hosts` entry for the container, + // so the `az` wrapper can resolve the engine by name without relying on + // Docker's embedded DNS — which AWF itself works around under gVisor and + // ARC/DinD. + let topology_attach_block = { + // The preceding `--network-isolation` continuation supplies the indent + // for the first line; any additional line must carry its own, matching + // `awf_image_flags`. + let mut block = format!("--topology-attach \"{MCPG_CONTAINER_NAME}\" \\\n"); + if ado_proxy_enabled { + block.push_str(&format!( + " --topology-attach \"{ADO_PROXY_CONTAINER_NAME}\" \\\n" + )); + } + block + }; + + // Trusted peers must bypass Squid: their names are not public DNS, and + // routing them through the proxy would break the very connection that + // reaches the policy engine. + let no_proxy_peers = if ado_proxy_enabled { + format!("{MCPG_CONTAINER_NAME},{ADO_PROXY_CONTAINER_NAME}") + } else { + MCPG_CONTAINER_NAME.to_string() + }; let routed_engine_run = format!( - "export NO_PROXY=\"${{NO_PROXY:+$NO_PROXY,}}{MCPG_CONTAINER_NAME}\"; \ + "export NO_PROXY=\"${{NO_PROXY:+$NO_PROXY,}}{no_proxy_peers}\"; \ export no_proxy=\"$NO_PROXY\"; {engine_run}" ); let script = format!( @@ -3037,7 +3075,7 @@ fn run_agent_step( \"$(Pipeline.Workspace)/awf/awf\" \\\n \ --allow-domains \"{allowed_domains}\" \\\n \ --network-isolation \\\n \ - --topology-attach \"{MCPG_CONTAINER_NAME}\" \\\n\ +{topology_attach_block}\ {image_flags_block}\ --skip-pull \\\n \ --env-all \\\n \ @@ -4148,6 +4186,82 @@ mod tests { } + // ── run_agent_step topology attachment ────────────────────────────────── + + fn agent_step_for_test(ado_proxy_enabled: bool) -> String { + run_agent_step( + "example.com", + "\\", + "/work", + "copilot -p prompt", + "FOO: bar", + &[], + None, + ado_proxy_enabled, + ) + .expect("run_agent_step should build") + .script + } + + #[test] + fn agent_attaches_only_mcpg_when_the_policy_engine_is_disabled() { + let script = agent_step_for_test(false); + assert_eq!( + script.matches("--topology-attach").count(), + 1, + "compiled output must be unchanged while the engine is unwired: {script}" + ); + assert!(!script.contains(ADO_PROXY_CONTAINER_NAME)); + } + + #[test] + fn agent_attaches_both_peers_when_the_policy_engine_is_enabled() { + let script = agent_step_for_test(true); + // Verified against the pinned AWF v0.27.32 binary, whose --help states + // the flag is "Repeatable" and gives a two-peer example. + assert_eq!(script.matches("--topology-attach").count(), 2); + assert!(script.contains(&format!("--topology-attach \"{MCPG_CONTAINER_NAME}\""))); + assert!(script.contains(&format!("--topology-attach \"{ADO_PROXY_CONTAINER_NAME}\""))); + } + + #[test] + fn trusted_peers_bypass_squid() { + // The peer names are not public DNS. Routing them through Squid would + // break the very connection that reaches the policy engine. + let disabled = agent_step_for_test(false); + assert!(disabled.contains(&format!("NO_PROXY:+$NO_PROXY,}}{MCPG_CONTAINER_NAME}"))); + assert!(!disabled.contains(ADO_PROXY_CONTAINER_NAME)); + + let enabled = agent_step_for_test(true); + assert!(enabled.contains(&format!( + "NO_PROXY:+$NO_PROXY,}}{MCPG_CONTAINER_NAME},{ADO_PROXY_CONTAINER_NAME}" + ))); + } + + #[test] + fn enabling_the_policy_engine_changes_only_attachment_and_no_proxy() { + // Guards against the continuation-indent damage that a hand-built + // multi-line flag block can silently do to the surrounding invocation. + let disabled = agent_step_for_test(false); + let enabled = agent_step_for_test(true); + + let normalize = |script: &str| { + script + .lines() + .filter(|line| { + !line.contains("--topology-attach") && !line.contains("NO_PROXY") + }) + .collect::>() + .join("\n") + }; + assert_eq!( + normalize(&disabled), + normalize(&enabled), + "no other part of the AWF invocation may shift" + ); + } + + // ── start_ado_proxy_step / stop_ado_proxy_step ────────────────────────── #[test] From cafb18799af72c9567cd6879b1ed0e8d9ea3f71c Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 3 Aug 2026 13:26:55 +0100 Subject: [PATCH 15/42] feat(ado-proxy): route the Azure DevOps MCP through the policy engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP container was handed the real Azure DevOps bearer via -e ADO_MCP_AUTH_TOKEN="$SC_READ_TOKEN" and put on --network host, so it reached Azure DevOps directly with a live credential the agent could steer through tool calls. This removes that path. The MCP now runs on a dedicated network shared only with the policy engine, with dev.azure.com redirected at the engine via --add-host, trusting the published interception CA, and holding a non-secret sentinel in place of a credential. AWF's DOCKER-USER rules are scoped to its own bridge, so they do not filter this network — the MCP can reach the engine and nothing else. It is also launched directly rather than through npx: the package is installed on the runner and mounted read-only at /app/node_modules, so the container needs no registry access. The mount point is load-bearing, since Node resolves dependencies by walking upward from the importing file. The pinned version is now enforced rather than merely requested: it is surfaced in the version catalog alongside AWF and MCPG, installed with --save-exact, and the resolved tree is checked against the pin. npm resolves ranges transitively, so a matching request does not by itself guarantee a matching tree — and the agent's tool surface is whatever ends up on disk. Capabilities default to the whole catalog. That is broad within a narrow boundary: every catalogued operation is a GET or OPTIONS, and secret-bearing route families (ACLs, tokens, service endpoints, variable groups, secure files) are denied outright. Starting narrower would leave the MCP unable to answer most questions, which pushes authors back towards handing agents raw credentials. Proven end to end before the compiler change was written, using the real @azure-devops/mcp 2.8.1 against a live engine: - core_list_projects returned the engine's own error text, so the call reached the policy engine with TLS trust intact and only a sentinel; - on the redirected host, writes are refused 403 "POST is not a read method", and denied families are refused 403 by name; - the MCP's startup tenant lookup targets a non-protected host and remains non-fatal under redirection. Six tests asserted the old behaviour, including the credential mapping itself. They are inverted into regression guards rather than deleted, so the hole cannot silently reopen. Compiled output confirms SC_READ_TOKEN now appears only in its acquisition step. Runner verification is still outstanding, as is the case where the engine address is unresolved — that now fails the build loudly rather than letting a client resolve the real Azure DevOps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- src/compile/agentic_pipeline.rs | 173 +++++++++++++++++++++-- src/compile/common.rs | 95 ++++++++++--- src/compile/extensions/tests.rs | 14 +- src/compile/mod.rs | 9 +- src/inspect/catalog.rs | 11 +- src/tools/azure_devops/extension.rs | 210 +++++++++++++++++++++++----- tests/compiler_tests.rs | 22 ++- 7 files changed, 456 insertions(+), 78 deletions(-) diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index f034af4e7..379304094 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -65,9 +65,10 @@ use std::path::Path; use super::common::PerJobPools; use super::common::{ - self, ADO_BUILD_ID_SUFFIX, ADO_PROXY_CONTAINER_NAME, ADO_PROXY_IMAGE, ADO_PROXY_LISTEN_PORT, - ADO_PROXY_TLS_PORT, AWF_SQUID_URL, AWF_VERSION, HEADER_MARKER, MCPG_CONTAINER_NAME, - MCPG_DOMAIN, MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, image_ref, + self, ADO_BUILD_ID_SUFFIX, ADO_MCP_HOST_NODE_MODULES, ADO_MCP_PACKAGE, ADO_MCP_VERSION, + ADO_PROXY_CONTAINER_NAME, ADO_PROXY_IMAGE, ADO_PROXY_LISTEN_PORT, ADO_PROXY_NETWORK_NAME, + ADO_PROXY_PUBLIC_CA_HOST_PATH, ADO_PROXY_TLS_PORT, AWF_SQUID_URL, AWF_VERSION, HEADER_MARKER, + MCPG_CONTAINER_NAME, MCPG_DOMAIN, MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, image_ref, }; use super::extensions::ado_script as paths; use crate::ado_proxy::catalog::{self, Capability}; @@ -989,6 +990,22 @@ fn build_agent_job( // 14. AWF path step (when extensions declare path prepends) push_raw_yaml_if_nonempty(&mut steps, &cfg.awf_path_step_yaml)?; + // 14a. Credential-isolated Azure DevOps policy engine. + // + // Must precede MCPG: the Azure DevOps MCP is redirected at the + // engine's container address, and that address does not exist until + // the engine is running. + let ado_proxy_enabled = front_matter + .tools + .as_ref() + .is_some_and(|tools| tools.azure_devops.is_some()); + if ado_proxy_enabled { + steps.push(Step::Bash(prepare_ado_proxy_clients_step())); + steps.push(Step::Bash(start_ado_proxy_step(&ado_proxy_capabilities( + front_matter, + )))); + } + // 15. MCP Gateway (MCPG), which launches SafeOutputs as a stdio child. steps.push(Step::Bash(start_mcpg_step( &cfg.mcpg_docker_env, @@ -1059,10 +1076,7 @@ fn build_agent_job( &cfg.engine_env, &cfg.byom_exclude_keys, front_matter.supply_chain(), - // Not yet enabled anywhere: the policy engine is wired in behind the - // `permissions.read` policy in a later change. Passing `false` keeps - // compiled output byte-identical until then. - false, + ado_proxy_enabled, )?)); // 18a. Revoke the GitHub App token (best-effort, always) once the Copilot @@ -1094,6 +1108,15 @@ fn build_agent_job( // 20. Stop MCPG and SafeOutputs steps.push(Step::Bash(stop_mcpg_step())); + // 20a. Stop the policy engine, then remove its network. `--rm` only fires + // on a clean exit, so an OOM or SIGKILL would otherwise leave the + // container — and the credential it holds in memory — running past + // the job. + if ado_proxy_enabled { + steps.push(Step::Bash(stop_ado_proxy_step())); + steps.push(Step::Bash(teardown_ado_proxy_network_step())); + } + // 21. User post_steps (finalize_steps) for user_step_val in &front_matter.post_steps { steps.push(Step::RawYaml(step_to_raw_yaml_string(user_step_val)?)); @@ -2850,8 +2873,18 @@ fn start_mcpg_step( -e \"s|\\${{MCP_RUNNER_UID}}|$MCP_RUNNER_UID|g\" \\\n \ -e \"s|\\${{MCP_RUNNER_GID}}|$MCP_RUNNER_GID|g\" \\\n \ -e \"s|\\${{MCP_GATEWAY_API_KEY}}|$(MCP_GATEWAY_API_KEY)|g\" \\\n \ + -e \"s|\\${{ADO_PROXY_IP}}|${{ADO_PROXY_IP:-}}|g\" \\\n \ /tmp/awf-tools/staging/mcpg-config.json)\n\ \n\ + # A client redirected at an empty address would resolve the real\n\ + # Azure DevOps instead of the policy engine, quietly restoring the\n\ + # direct path this design removes. Fail loudly rather than start.\n\ + if grep -q 'ADO_PROXY_IP' /tmp/awf-tools/staging/mcpg-config.json \\\n \ + && [ -z \"${{ADO_PROXY_IP:-}}\" ]; then\n \ + echo \"##vso[task.complete result=Failed]ado-proxy address is unknown; refusing to start MCP clients unredirected\"\n \ + exit 1\n\ + fi\n\ + \n\ # Log the template config (before API key substitution) for debugging.\n\ echo \"Starting MCPG with config template:\"\n\ python3 -m json.tool < /tmp/awf-tools/staging/mcpg-config.json\n\ @@ -3195,6 +3228,91 @@ fn safe_outputs_summary_step(reviewed: &[String]) -> BashStep { .with_condition(Condition::Always) } +/// Resolve the capabilities the policy engine should enable. +/// +/// Defaults to the full catalog. That is deliberately broad *within* a narrow +/// boundary: every catalogued operation is a `GET` or `OPTIONS`, and the +/// always-denied route families exclude ACLs, tokens, service endpoints, +/// variable groups and secure files. So the default grants read access to +/// project metadata the agent could already reach, while removing the +/// credential that previously made writes and secret reads possible at all. +/// +/// Starting narrower would leave the Azure DevOps MCP unable to answer most +/// questions, which pushes authors back towards handing agents raw +/// credentials — the outcome this design exists to prevent. `permissions.read` +/// narrows this set once its object form is accepted. +fn ado_proxy_capabilities(_front_matter: &FrontMatter) -> Vec { + Capability::ALL.to_vec() +} + +/// Prepare the host-side prerequisites for routing the Azure DevOps MCP +/// through the policy engine. +/// +/// Two things the engine cannot do for itself: +/// +/// 1. **A shared network.** The MCP reaches the engine here rather than over +/// AWF's network, which it is not attached to. AWF's `DOCKER-USER` rules are +/// scoped to its own bridge, so they do not filter this one — the MCP can +/// reach the engine, and nothing else. +/// 2. **The MCP package.** It is installed on the runner, which has registry +/// access, and mounted read-only into a container that does not. That keeps +/// the MCP image stock (`node:20-slim`) so nothing new enters the supply +/// chain, and removes `npx`'s start-time registry dependency. +/// +/// The mount point is load-bearing: Node resolves dependencies by walking +/// upward from the importing file, so the tree must land at +/// `/app/node_modules` or the MCP's own imports fail to resolve. +fn prepare_ado_proxy_clients_step() -> BashStep { + let script = format!( + "set -euo pipefail\n\ + \n\ + # Network shared by the policy engine and the Azure DevOps MCP.\n\ + if ! docker network inspect {ADO_PROXY_NETWORK_NAME} >/dev/null 2>&1; then\n \ + docker network create {ADO_PROXY_NETWORK_NAME}\n\ + fi\n\ + \n\ + # Install the MCP on the runner and stage it for mounting. The\n\ + # container it is mounted into can reach nothing but the engine, so it\n\ + # cannot fetch this itself.\n\ + MCP_STAGE=\"$(dirname {ADO_MCP_HOST_NODE_MODULES})\"\n\ + rm -rf \"$MCP_STAGE\"\n\ + mkdir -p \"$MCP_STAGE\"\n\ + cd \"$MCP_STAGE\"\n\ + npm init -y >/dev/null 2>&1\n\ + npm install --omit=dev --no-audit --no-fund --save-exact \\\n \ + \"{ADO_MCP_PACKAGE}@{ADO_MCP_VERSION}\"\n\ + \n\ + # Verify the pin actually took. `npm install` resolves a *range* for\n\ + # anything it also has to satisfy transitively, so a matching request\n\ + # does not by itself guarantee a matching tree — and the agent's tool\n\ + # surface is defined by whatever ends up on disk here.\n\ + MCP_INSTALLED=$(node -p \\\n \ + \"require('{ADO_MCP_HOST_NODE_MODULES}/{ADO_MCP_PACKAGE}/package.json').version\")\n\ + if [ \"$MCP_INSTALLED\" != \"{ADO_MCP_VERSION}\" ]; then\n \ + echo \"##vso[task.complete result=Failed]Azure DevOps MCP resolved to $MCP_INSTALLED, expected {ADO_MCP_VERSION}\"\n \ + exit 1\n\ + fi\n\ + \n\ + # Fail here rather than at MCP start time, where a missing entry\n\ + # script surfaces as an opaque MCPG backend error.\n\ + if [ ! -f \"{ADO_MCP_HOST_NODE_MODULES}/{ADO_MCP_PACKAGE}/dist/index.js\" ]; then\n \ + echo \"##vso[task.complete result=Failed]Azure DevOps MCP package did not install\"\n \ + exit 1\n\ + fi\n\ + echo \"Azure DevOps MCP $MCP_INSTALLED staged at {ADO_MCP_HOST_NODE_MODULES}\"\n" + ); + bash("Prepare Azure DevOps MCP and proxy network", script) +} + +/// Remove the network created for the policy engine and its clients. +fn teardown_ado_proxy_network_step() -> BashStep { + let script = format!( + "# Remove the policy-engine network once its containers are gone\n\ + docker network rm {ADO_PROXY_NETWORK_NAME} 2>/dev/null || true\n" + ); + bash("Remove ado-proxy network", script).with_condition(Condition::Always) +} + fn stop_mcpg_step() -> BashStep { let script = format!( "# Stop MCPG container\n\ @@ -3224,7 +3342,6 @@ fn stop_mcpg_step() -> BashStep { /// design exists to withhold. /// /// Not yet emitted: see [`stop_ado_proxy_step`]. -#[allow(dead_code)] fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { let policy = PolicyDocument::new(capabilities).to_json(); let hosts = catalog::catalog().protected_hosts; @@ -3288,7 +3405,7 @@ fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { # MCP must be able to read. The matching private key never leaves\n\ # $PROXY_DIR and is destroyed below.\n\ mkdir -p /tmp/gh-aw/ado-proxy\n\ - echo \"##vso[task.setvariable variable=ADO_PROXY_CA_FILE]/tmp/gh-aw/ado-proxy/ado-proxy-ca.pem\"\n\ + echo \"##vso[task.setvariable variable=ADO_PROXY_CA_FILE]{ca_host_path}\"\n\ \n\ # Build the material document. jq assembles it so that a value\n\ # containing JSON metacharacters cannot alter the document shape.\n\ @@ -3311,7 +3428,7 @@ fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { \n\ printf '%s' \"$PROXY_MATERIAL\" | docker run -i --rm \\\n \ --name {ADO_PROXY_CONTAINER_NAME} \\\n \ - --network bridge \\\n \ + --network {ADO_PROXY_NETWORK_NAME} \\\n \ -v \"{ado_proxy_path}:/app/ado-proxy.js:ro\" \\\n \ -v \"$PROXY_DIR/policy:/etc/ado-proxy:ro\" \\\n \ -v /tmp/gh-aw/ado-proxy:/var/lib/ado-proxy \\\n \ @@ -3352,6 +3469,7 @@ fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { echo \"ado-proxy is ready at $ADO_PROXY_IP\"\n\ echo \"##vso[task.setvariable variable=ADO_PROXY_IP]$ADO_PROXY_IP\"\n", ado_proxy_path = paths::ADO_PROXY_PATH, + ca_host_path = ADO_PROXY_PUBLIC_CA_HOST_PATH, ado_proxy_image = ADO_PROXY_IMAGE, squid_url = AWF_SQUID_URL, listen_port = ADO_PROXY_LISTEN_PORT, @@ -3374,7 +3492,6 @@ fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { /// Not yet emitted: the Agent job gains these steps in `proxy-topology-attach`, /// once AWF is also told to attach the container. Landing the lifecycle first /// keeps that change to the wiring alone. -#[allow(dead_code)] fn stop_ado_proxy_step() -> BashStep { let script = format!( "# Stop the ado-proxy policy engine\n\ @@ -4186,6 +4303,40 @@ mod tests { } + #[test] + fn the_policy_engine_starts_before_the_mcp_gateway() { + // The Azure DevOps MCP is redirected at the engine's container + // address, which does not exist until the engine is running. Starting + // MCPG first would leave the redirect unresolvable. + let script = prepare_ado_proxy_clients_step().script; + assert!(script.contains(&format!("docker network create {ADO_PROXY_NETWORK_NAME}"))); + assert!( + script.contains(&format!("{ADO_MCP_PACKAGE}@{ADO_MCP_VERSION}")), + "the MCP package must be pinned, not floating: {script}" + ); + assert!( + script.contains("--save-exact"), + "an unpinned resolve would vary the agent's tool surface between runs" + ); + assert!( + script.contains("$MCP_INSTALLED\" != \"") , + "the resolved version must be verified, not just requested: {script}" + ); + } + + #[test] + fn the_default_capability_set_is_the_whole_catalog() { + // Deliberately broad within a narrow boundary: every catalogued + // operation is a GET or OPTIONS, and secret-bearing route families are + // denied outright. Starting narrower would leave the MCP unable to + // answer most questions, pushing authors back to raw credentials. + let (fm, _) = crate::compile::parse_markdown( + "---\nname: t\ndescription: x\ntools:\n azure-devops:\n org: 'myorg'\n---\n", + ) + .unwrap(); + assert_eq!(ado_proxy_capabilities(&fm), Capability::ALL.to_vec()); + } + // ── run_agent_step topology attachment ────────────────────────────────── fn agent_step_for_test(ado_proxy_enabled: bool) -> String { diff --git a/src/compile/common.rs b/src/compile/common.rs index 5a596cf5b..0dd56bc2e 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1599,11 +1599,64 @@ pub const MCPG_CONTAINER_NAME: &str = MCPG_DOMAIN; pub const ADO_MCP_IMAGE: &str = "node:20-slim"; /// Default entrypoint for the Azure DevOps MCP container. -pub const ADO_MCP_ENTRYPOINT: &str = "npx"; +/// +/// The MCP is launched directly rather than through `npx`: the package is +/// installed on the runner and mounted in, so the container needs no registry +/// access and resolves nothing at start time. +pub const ADO_MCP_ENTRYPOINT: &str = "node"; + +/// Mount point for the pre-installed Azure DevOps MCP package. +/// +/// Load-bearing: Node resolves dependencies by walking *upward* from the +/// importing file, so the tree must sit at `/app/node_modules` for the MCP's +/// own dependencies to resolve. Mounting it anywhere else fails at import with +/// `ERR_MODULE_NOT_FOUND` for `@modelcontextprotocol/sdk`. +pub const ADO_MCP_NODE_MODULES: &str = "/app/node_modules"; + +/// Entry script of the Azure DevOps MCP package inside the container. +pub const ADO_MCP_ENTRY_SCRIPT: &str = "/app/node_modules/@azure-devops/mcp/dist/index.js"; + +/// Where the runner stages the installed MCP package for mounting. +pub const ADO_MCP_HOST_NODE_MODULES: &str = "/tmp/ado-aw-mcp/node_modules"; + +/// Non-secret placeholder handed to the MCP in place of an Azure DevOps token. +/// +/// The MCP authenticates with whatever is in `ADO_MCP_AUTH_TOKEN`, but under +/// interception it never talks to Azure DevOps directly: the policy engine +/// strips every client credential and attaches the real bearer only after a +/// complete allow decision. Passing the real token here would make the proxy +/// decorative on this path — the MCP could authenticate directly if it ever +/// reached Azure DevOps by another route, and the credential would sit in a +/// container the agent can influence through tool calls. +/// +/// Deliberately self-describing: it shows up in logs and error messages, where +/// it should read as intentional rather than as a misconfiguration. +pub const ADO_MCP_TOKEN_SENTINEL: &str = "ado-proxy-injects-the-real-credential"; + +/// Docker network shared by the policy engine and the Azure DevOps MCP. +/// +/// Separate from AWF's `awf-net`: AWF's `DOCKER-USER` rules are scoped to its +/// own bridge (`-i `), so they do not filter this network. That is +/// what lets the MCP reach the engine here while still having no unpoliced +/// route out — its only reachable peer is the engine. +pub const ADO_PROXY_NETWORK_NAME: &str = "ado-aw-proxy-net"; + +/// Path the public interception CA is mounted at inside client containers. +pub const ADO_MCP_CA_MOUNT: &str = "/etc/ado-proxy/ca.pem"; + +/// Runner-side path of the CA certificate the policy engine publishes. +pub const ADO_PROXY_PUBLIC_CA_HOST_PATH: &str = "/tmp/gh-aw/ado-proxy/ado-proxy-ca.pem"; /// Default entrypoint args for the Azure DevOps MCP npm package. pub const ADO_MCP_PACKAGE: &str = "@azure-devops/mcp"; +/// Pinned Azure DevOps MCP package version. +/// +/// Pinned rather than floating because the package is fetched on the runner +/// and mounted into a container that has no registry access of its own; an +/// unpinned fetch would make the agent's tool surface vary run to run. +pub const ADO_MCP_VERSION: &str = "2.8.1"; + /// Reserved MCPG server name for the auto-configured ADO MCP. pub const ADO_MCP_SERVER_NAME: &str = "azure-devops"; @@ -6331,16 +6384,17 @@ safe-outputs: #[test] fn test_generate_mcpg_docker_env_with_permissions_read() { - // When ADO tool is enabled with permissions.read, the extension's - // required_pipeline_vars should produce the -e flag + // `permissions.read` selects the service connection the *engine* uses. + // It must not cause the token to be projected into MCPG's own + // environment, from where it would reach the MCP container. let (fm, _) = parse_markdown( "---\nname: test\ndescription: test\ntools:\n azure-devops: true\npermissions:\n read: my-read-sc\n---\n", ).unwrap(); let (_extensions, declarations) = collect_exts_and_decls_with_org(&fm, "myorg"); let env = generate_mcpg_docker_env(&fm, &declarations); assert!( - env.contains("-e ADO_MCP_AUTH_TOKEN=\"$SC_READ_TOKEN\""), - "Should map ADO token via extension pipeline var" + !env.contains("-e ADO_MCP_AUTH_TOKEN=\"$SC_READ_TOKEN\""), + "the credential must not be mapped into MCPG: {env}" ); } @@ -6439,6 +6493,9 @@ safe-outputs: #[test] fn test_generate_mcpg_step_env_with_ado_extension() { + // The ADO tool no longer contributes any pipeline variable: it used to + // map SC_READ_TOKEN into the MCPG step so the MCP could authenticate + // directly. Nothing should replace it. let (fm, _) = parse_markdown( "---\nname: test\ndescription: test\ntools:\n azure-devops: true\n---\n", ) @@ -6446,12 +6503,8 @@ safe-outputs: let (_extensions, declarations) = collect_exts_and_decls_with_org(&fm, "myorg"); let env = generate_mcpg_step_env(&declarations); assert!( - env.starts_with("env:\n"), - "Should emit full env: block header" - ); - assert!( - env.contains("SC_READ_TOKEN: $(SC_READ_TOKEN)"), - "Should map SC_READ_TOKEN for ADO extension" + !env.contains("SC_READ_TOKEN"), + "the ADO tool must not surface the read token to MCPG: {env}" ); } @@ -6513,12 +6566,15 @@ safe-outputs: assert_eq!(ado.container.as_deref(), Some(ADO_MCP_IMAGE)); assert_eq!(ado.entrypoint.as_deref(), Some(ADO_MCP_ENTRYPOINT)); let args = ado.entrypoint_args.as_ref().unwrap(); - assert!(args.contains(&"-y".to_string())); - assert!(args.contains(&ADO_MCP_PACKAGE.to_string())); + assert!(args.contains(&ADO_MCP_ENTRY_SCRIPT.to_string())); assert!(args.contains(&"inferred-org".to_string())); - // Should have ADO_MCP_AUTH_TOKEN in env (for bearer token via envvar auth) + // The token is a sentinel: the engine injects the real bearer only + // after a complete allow decision. let env = ado.env.as_ref().unwrap(); - assert!(env.contains_key("ADO_MCP_AUTH_TOKEN")); + assert_eq!( + env.get("ADO_MCP_AUTH_TOKEN").map(String::as_str), + Some(ADO_MCP_TOKEN_SENTINEL) + ); } #[test] @@ -6673,6 +6729,11 @@ safe-outputs: #[test] fn test_ado_tool_docker_env_passthrough() { + // Regression guard for the hole this proxy closes: the MCP used to be + // handed the real Azure DevOps bearer via + // `-e ADO_MCP_AUTH_TOKEN="$SC_READ_TOKEN"`. Under interception the + // policy engine holds the only copy, so nothing may project a + // credential into the MCP container. let (fm, _) = parse_markdown( "---\nname: test\ndescription: test\ntools:\n azure-devops: true\npermissions:\n read: my-read-sc\n---\n", ) @@ -6680,8 +6741,8 @@ safe-outputs: let (_extensions, declarations) = collect_exts_and_decls_with_org(&fm, "myorg"); let env = generate_mcpg_docker_env(&fm, &declarations); assert!( - env.contains("ADO_MCP_AUTH_TOKEN"), - "Should include ADO token passthrough when permissions.read is set" + !env.contains("SC_READ_TOKEN"), + "the real bearer must never reach the MCP container: {env}" ); } diff --git a/src/compile/extensions/tests.rs b/src/compile/extensions/tests.rs index 32aa32d93..d4b6a1cbe 100644 --- a/src/compile/extensions/tests.rs +++ b/src/compile/extensions/tests.rs @@ -301,12 +301,14 @@ fn test_lean_validate_bash_not_disabled_no_warning() { #[test] fn test_ado_required_hosts() { + // The MCP is redirected at the policy engine and fetches nothing at start + // time, so it needs no allow-listed hosts of its own. Re-adding + // dev.azure.com here would only matter if something reached it directly — + // which is exactly what this design removes. let ext = AzureDevOpsExtension::new(AzureDevOpsToolConfig::Enabled(true)); let fm = minimal_front_matter(); let hosts = declarations_with_org(&ext, &fm).network_hosts; - assert!(hosts.contains(&"dev.azure.com".to_string())); - // Node ecosystem is required for npx to resolve @azure-devops/mcp - assert!(hosts.contains(&"node".to_string())); + assert!(hosts.is_empty(), "expected no direct egress hosts: {hosts:?}"); } #[test] @@ -326,9 +328,11 @@ fn test_ado_mcpg_servers_with_inferred_org() { .unwrap() .contains(&"myorg".to_string()) ); - // Trusted MCP backends retain direct host-network egress outside AWF. + // Host networking would put the MCP on the runner's own stack, where it + // could reach Azure DevOps directly and bypass the policy entirely. let args = servers[0].1.args.as_ref().expect("args should be set"); - assert_eq!(args, &vec!["--network".to_string(), "host".to_string()]); + assert!(!args.contains(&"host".to_string()), "{args:?}"); + assert!(args.contains(&"--add-host".to_string()), "{args:?}"); } #[test] diff --git a/src/compile/mod.rs b/src/compile/mod.rs index bc522ee8c..94848ee9f 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -35,10 +35,17 @@ use async_trait::async_trait; use log::{debug, info}; use std::path::{Path, PathBuf}; +pub use common::ADO_MCP_CA_MOUNT; +pub use common::ADO_MCP_ENTRY_SCRIPT; pub use common::ADO_MCP_ENTRYPOINT; +pub use common::ADO_MCP_HOST_NODE_MODULES; pub use common::ADO_MCP_IMAGE; -pub use common::ADO_MCP_PACKAGE; +pub use common::ADO_MCP_NODE_MODULES; pub use common::ADO_MCP_SERVER_NAME; +pub use common::ADO_MCP_VERSION; +pub use common::ADO_MCP_TOKEN_SENTINEL; +pub use common::ADO_PROXY_NETWORK_NAME; +pub use common::ADO_PROXY_PUBLIC_CA_HOST_PATH; pub use common::AWF_VERSION; pub use common::HEADER_MARKER; pub use common::MCPG_VERSION; diff --git a/src/inspect/catalog.rs b/src/inspect/catalog.rs index 8e127b811..2f325762a 100644 --- a/src/inspect/catalog.rs +++ b/src/inspect/catalog.rs @@ -5,7 +5,7 @@ use std::fmt; use serde::Serialize; -use crate::compile::{AWF_VERSION, MCPG_VERSION}; +use crate::compile::{ADO_MCP_VERSION, AWF_VERSION, MCPG_VERSION}; use crate::engine::{COPILOT_CLI_VERSION, DEFAULT_COPILOT_MODEL}; use crate::safe_outputs::{ALL_KNOWN_SAFE_OUTPUTS, ALWAYS_ON_TOOLS, DEBUG_ONLY_TOOLS}; @@ -42,6 +42,13 @@ pub struct VersionCatalog { pub awf: String, /// Pinned MCP Gateway version (`compile::common::MCPG_VERSION`). pub mcpg: String, + /// Pinned Azure DevOps MCP npm package version + /// (`compile::common::ADO_MCP_VERSION`). + /// + /// Fetched on the runner and mounted into a container with no registry + /// access of its own, so an unpinned fetch would let the agent's tool + /// surface vary between runs. + pub ado_mcp: String, } #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] @@ -202,6 +209,7 @@ pub fn render_text(catalog: &Catalog) -> String { out.push_str(&format!(" copilot-cli {}\n", versions.copilot_cli)); out.push_str(&format!(" awf {}\n", versions.awf)); out.push_str(&format!(" mcpg {}\n", versions.mcpg)); + out.push_str(&format!(" ado-mcp {}\n", versions.ado_mcp)); out.push('\n'); } if let Some(proxy) = &catalog.ado_proxy { @@ -233,6 +241,7 @@ fn versions() -> VersionCatalog { copilot_cli: COPILOT_CLI_VERSION.to_string(), awf: AWF_VERSION.to_string(), mcpg: MCPG_VERSION.to_string(), + ado_mcp: ADO_MCP_VERSION.to_string(), } } diff --git a/src/tools/azure_devops/extension.rs b/src/tools/azure_devops/extension.rs index 6bfb931fb..f7fe9dbc7 100644 --- a/src/tools/azure_devops/extension.rs +++ b/src/tools/azure_devops/extension.rs @@ -1,11 +1,15 @@ // ─── Azure DevOps MCP ──────────────────────────────────────────────── -use crate::allowed_hosts::mcp_required_hosts; +use crate::ado_proxy::catalog::ORGANIZATION_HOST; use crate::compile::extensions::{ CompileContext, CompilerExtension, Declarations, ExtensionPhase, McpgServerConfig, }; use crate::compile::types::AzureDevOpsToolConfig; -use crate::compile::{ADO_MCP_ENTRYPOINT, ADO_MCP_IMAGE, ADO_MCP_PACKAGE, ADO_MCP_SERVER_NAME}; +use crate::compile::{ + ADO_MCP_CA_MOUNT, ADO_MCP_ENTRY_SCRIPT, ADO_MCP_ENTRYPOINT, ADO_MCP_HOST_NODE_MODULES, + ADO_MCP_IMAGE, ADO_MCP_NODE_MODULES, ADO_MCP_SERVER_NAME, ADO_MCP_TOKEN_SENTINEL, + ADO_PROXY_NETWORK_NAME, ADO_PROXY_PUBLIC_CA_HOST_PATH, +}; use anyhow::Result; use std::collections::BTreeMap; @@ -35,16 +39,17 @@ impl CompilerExtension for AzureDevOpsExtension { /// Typed-IR view. Azure DevOps MCP contributes only static /// signals — no pipeline steps. fn declarations(&self, ctx: &CompileContext) -> Result { - let mut hosts: Vec = mcp_required_hosts("ado") - .iter() - .map(|h| (*h).to_string()) - .collect(); - // The ADO MCP runs in a container via `npx -y @azure-devops/mcp`. - // npx needs npm registry access to resolve and install the package. - hosts.push("node".to_string()); + // The MCP no longer reaches Azure DevOps itself: it is redirected at + // the policy engine, which holds the credential. The engine's own + // egress goes through Squid, so the hosts the MCP would otherwise + // need are not required here. `node` is likewise gone — the package + // is installed on the runner and mounted in, so the container + // resolves nothing at start time. + let hosts: Vec = Vec::new(); - // Build entrypoint args: npx -y @azure-devops/mcp [-d toolset1 toolset2 ...] - let mut entrypoint_args = vec!["-y".to_string(), ADO_MCP_PACKAGE.to_string()]; + // Launch the package directly. `npx` would need registry access from + // inside a container that, by design, can reach nothing but the engine. + let mut entrypoint_args = vec![ADO_MCP_ENTRY_SCRIPT.to_string()]; // Org: use explicit override, then inferred from git remote, then fail let org = self @@ -93,21 +98,47 @@ impl CompilerExtension for AzureDevOpsExtension { }; // ADO MCP authentication: the @azure-devops/mcp npm package accepts - // auth type via CLI arg (-a) and token via env var. - // Bearer: `-a envvar` reads ADO_MCP_AUTH_TOKEN (pipeline JWT from ARM) - let (auth_flag, token_var) = ("envvar", "ADO_MCP_AUTH_TOKEN"); - entrypoint_args.extend(["-a".to_string(), auth_flag.to_string()]); - - let env = Some(BTreeMap::from([( - token_var.to_string(), - String::new(), // Passthrough from MCPG process env - )])); - - // --network host: AWF's DOCKER-USER iptables rules block outbound from - // containers on Docker's default bridge. Host networking bypasses FORWARD - // chain rules so the ADO MCP can reach dev.azure.com. - // This matches gh-aw's approach for its built-in agentic-workflows MCP. - let args = Some(vec!["--network".to_string(), "host".to_string()]); + // auth type via CLI arg (-a) and token via env var. Under interception + // the value is a sentinel — the engine injects the real bearer only + // after a complete allow decision. + entrypoint_args.extend(["-a".to_string(), "envvar".to_string()]); + + let env = Some(BTreeMap::from([ + ( + "ADO_MCP_AUTH_TOKEN".to_string(), + ADO_MCP_TOKEN_SENTINEL.to_string(), + ), + // Trust is scoped to this container rather than installed + // system-wide: it is an availability control, not a security one. + // Enforcement comes from routing — Squid denies the protected + // hosts, so a client that declines this certificate fails closed + // instead of escaping the policy. + ( + "NODE_EXTRA_CA_CERTS".to_string(), + ADO_MCP_CA_MOUNT.to_string(), + ), + ])); + + // Mount the pre-installed package and the *public* CA certificate. + // The CA private key is never mounted anywhere; it is destroyed by the + // step that starts the engine. + let mounts = Some(vec![ + format!("{ADO_MCP_HOST_NODE_MODULES}:{ADO_MCP_NODE_MODULES}:ro"), + format!("{ADO_PROXY_PUBLIC_CA_HOST_PATH}:{ADO_MCP_CA_MOUNT}:ro"), + ]); + + // Join the engine's network and redirect the Azure DevOps host at it. + // `--add-host` is what makes the redirection total: it catches both + // `node:https` and global `fetch`, so the MCP's raw `fetch()` call + // sites cannot slip past it the way proxy environment variables would. + // `ADO_PROXY_IP` is resolved at pipeline time and substituted into the + // MCPG config by the step that starts the engine. + let args = Some(vec![ + "--network".to_string(), + ADO_PROXY_NETWORK_NAME.to_string(), + "--add-host".to_string(), + format!("{ORGANIZATION_HOST}:${{ADO_PROXY_IP}}"), + ]); let mcpg_servers = vec![( ADO_MCP_SERVER_NAME.to_string(), @@ -116,7 +147,7 @@ impl CompilerExtension for AzureDevOpsExtension { container: Some(ADO_MCP_IMAGE.to_string()), entrypoint: Some(ADO_MCP_ENTRYPOINT.to_string()), entrypoint_args: Some(entrypoint_args), - mounts: None, + mounts, args, url: None, headers: None, @@ -145,10 +176,11 @@ impl CompilerExtension for AzureDevOpsExtension { network_hosts: hosts, mcpg_servers, copilot_allow_tools: vec![ADO_MCP_SERVER_NAME.to_string()], - pipeline_env: vec![crate::compile::extensions::PipelineEnvMapping { - container_var: "ADO_MCP_AUTH_TOKEN".to_string(), - pipeline_var: "SC_READ_TOKEN".to_string(), - }], + // Deliberately empty. This previously mapped + // ADO_MCP_AUTH_TOKEN -> SC_READ_TOKEN, handing the MCP container a + // real Azure DevOps credential. Under interception the engine holds + // the only copy; the MCP gets a sentinel. + pipeline_env: Vec::new(), warnings, ..Declarations::default() }) @@ -193,11 +225,117 @@ mod tests { assert_eq!(config.server_type, "stdio"); assert_eq!(config.container.as_deref(), Some(ADO_MCP_IMAGE)); - // pipeline_env exposes the ADO_MCP_AUTH_TOKEN passthrough. - assert_eq!(decl.pipeline_env.len(), 1); - assert_eq!(decl.pipeline_env[0].container_var, "ADO_MCP_AUTH_TOKEN"); + // The MCP must never receive a real Azure DevOps credential: the + // policy engine holds the only copy and injects it after an allow + // decision. This is the whole point of routing it through the proxy. + assert!( + decl.pipeline_env.is_empty(), + "no pipeline variable may be projected into the MCP container: {:?}", + decl.pipeline_env + ); + let env = config.env.as_ref().expect("env is set"); + assert_eq!( + env.get("ADO_MCP_AUTH_TOKEN").map(String::as_str), + Some(ADO_MCP_TOKEN_SENTINEL) + ); + + // Nothing is fetched at start time, so the container needs no hosts. + assert!( + decl.network_hosts.is_empty(), + "the MCP reaches only the policy engine: {:?}", + decl.network_hosts + ); + } + + fn config_for(markdown: &str) -> (crate::compile::types::FrontMatter, AzureDevOpsToolConfig) { + let (fm, _) = parse_markdown(markdown).unwrap(); + let cfg = fm + .tools + .as_ref() + .and_then(|t| t.azure_devops.as_ref()) + .cloned() + .unwrap(); + (fm, cfg) + } + + const MINIMAL: &str = + "---\nname: t\ndescription: x\ntools:\n azure-devops:\n org: 'myorg'\n---\n"; + + #[test] + fn mcp_is_launched_directly_rather_than_resolved_at_start_time() { + let (fm, cfg) = config_for(MINIMAL); + let ctx = CompileContext::for_test(&fm); + let decl = AzureDevOpsExtension::new(cfg).declarations(&ctx).unwrap(); + let (_, config) = &decl.mcpg_servers[0]; + + // `npx` would need registry access from a container that, by design, + // can reach nothing but the policy engine. + assert_eq!(config.entrypoint.as_deref(), Some("node")); + let args = config.entrypoint_args.as_ref().unwrap(); + assert_eq!(args[0], ADO_MCP_ENTRY_SCRIPT); + assert!(!args.iter().any(|a| a == "-y")); + } + + #[test] + fn mcp_is_redirected_at_the_policy_engine() { + let (fm, cfg) = config_for(MINIMAL); + let ctx = CompileContext::for_test(&fm); + let decl = AzureDevOpsExtension::new(cfg).declarations(&ctx).unwrap(); + let (_, config) = &decl.mcpg_servers[0]; + let args = config.args.as_ref().expect("docker args set"); - // Network hosts include the dev.azure.com domains plus node. - assert!(decl.network_hosts.contains(&"node".to_string())); + // Host networking would put the MCP on the runner's own stack, where + // it could reach Azure DevOps directly and bypass the policy entirely. + assert!( + !args.iter().any(|a| a == "host"), + "the MCP must not use host networking: {args:?}" + ); + assert!(args.windows(2).any(|w| w == ["--network", ADO_PROXY_NETWORK_NAME])); + assert!( + args.iter() + .any(|a| a == &format!("{ORGANIZATION_HOST}:${{ADO_PROXY_IP}}")), + "the Azure DevOps host must resolve to the engine: {args:?}" + ); + } + + #[test] + fn mcp_mounts_the_package_and_only_the_public_certificate() { + let (fm, cfg) = config_for(MINIMAL); + let ctx = CompileContext::for_test(&fm); + let decl = AzureDevOpsExtension::new(cfg).declarations(&ctx).unwrap(); + let (_, config) = &decl.mcpg_servers[0]; + let mounts = config.mounts.as_ref().expect("mounts set"); + + // Node resolves dependencies by walking upward from the importing + // file, so this path is load-bearing: mounted elsewhere, the MCP's own + // imports fail with ERR_MODULE_NOT_FOUND. + assert!( + mounts + .iter() + .any(|m| m == &format!("{ADO_MCP_HOST_NODE_MODULES}:{ADO_MCP_NODE_MODULES}:ro")), + "{mounts:?}" + ); + assert!(mounts.iter().all(|m| m.ends_with(":ro")), "{mounts:?}"); + assert!( + mounts.iter().any(|m| m.contains(ADO_MCP_CA_MOUNT)), + "the MCP must trust the interception certificate: {mounts:?}" + ); + assert!( + !mounts.iter().any(|m| m.contains(".key")), + "the CA private key must never be mounted: {mounts:?}" + ); + let env = config.env.as_ref().unwrap(); + assert_eq!( + env.get("NODE_EXTRA_CA_CERTS").map(String::as_str), + Some(ADO_MCP_CA_MOUNT) + ); + } + + #[test] + fn the_sentinel_is_not_a_credential() { + // It appears in logs and error messages, so it must read as + // deliberate rather than as a leaked or malformed token. + assert!(ADO_MCP_TOKEN_SENTINEL.contains("ado-proxy")); + assert!(!ADO_MCP_TOKEN_SENTINEL.is_empty()); } } diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 076e37b09..629a22fbb 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1933,22 +1933,30 @@ fn test_fixture_azure_devops_mcp_compiled_output() { "MCPG config should NOT use command field" ); - // Should contain env for ADO_MCP_AUTH_TOKEN (envvar auth for @azure-devops/mcp) + // The MCP receives a sentinel, never a credential: the policy engine holds + // the only copy and attaches it after a complete allow decision. assert!( compiled.contains("ADO_MCP_AUTH_TOKEN"), "Should reference ADO_MCP_AUTH_TOKEN" ); + assert!( + compiled.contains("ado-proxy-injects-the-real-credential"), + "the MCP's token must be the non-secret sentinel" + ); - // Should contain SC_READ_TOKEN (from permissions.read) + // Regression guard for the hole the policy engine closes. `SC_READ_TOKEN` + // still appears elsewhere in the pipeline — the engine is given it — but it + // must never be projected into the MCP container. assert!( - compiled.contains("SC_READ_TOKEN"), - "Should contain SC_READ_TOKEN" + !compiled.contains("-e ADO_MCP_AUTH_TOKEN=\"$SC_READ_TOKEN\""), + "the real Azure DevOps bearer must not reach the MCP container" ); - // Should contain the MCPG docker env passthrough (auto-mapped ADO token) + // Host networking would put the MCP on the runner's stack, where it could + // reach Azure DevOps directly and bypass the policy entirely. assert!( - compiled.contains("-e ADO_MCP_AUTH_TOKEN=\"$SC_READ_TOKEN\""), - "Should auto-map SC_READ_TOKEN to ADO_MCP_AUTH_TOKEN on MCPG Docker run" + compiled.contains("--add-host"), + "the MCP must be redirected at the policy engine" ); let _ = fs::remove_dir_all(&temp_dir); From 3a91f3e8ee104962204dea6ca387b736091cd2dc Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 3 Aug 2026 14:23:36 +0100 Subject: [PATCH 16/42] fix(ado-proxy): isolate the MCP network so it cannot route past the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP shared a normal user-defined bridge with the policy engine. Those have outbound NAT, so the MCP kept a direct route to the internet — including every Azure DevOps host the --add-host redirect does not override — and the engine policed one hostname rather than a boundary. Create the network --internal instead. Measured rather than reasoned about: a container on a normal bridge reached example.com with status 200; on an --internal bridge the same request failed while the container still routed to its peers; and a dual-homed container kept full egress via its second network. That last point is what makes the fix safe — the engine still reaches Squid over awf-net, which AWF attaches it to. This corrects a claim in the design doc that AWF's DOCKER-USER scoping alone left the MCP with no unpoliced route out. Also records the Wave 2 acceptance evidence, gathered against a live chain of the real MCP, the real engine, a fake Squid and a fake upstream: - a client reached the upstream and got 200 with real JSON; - every request arriving upstream carried the INJECTED canary bearer, and the sentinel the client held never appeared there; - variablegroups, serviceendpoint, a POST write and an unknown route were each refused 403 with distinct reasons, and the upstream request count was unchanged across all four; - scanning the MCP container's environment, mounts, /tmp and process table for the canary found zero occurrences; - npm view failed EAI_AGAIN inside the MCP container, yet the MCP still completed an initialize handshake from the mounted package. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 7 ++++++- src/compile/agentic_pipeline.rs | 29 +++++++++++++++++++++++++++-- src/compile/common.rs | 10 ++++++---- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index 6954e8a5e..99917db6b 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -84,7 +84,7 @@ there is exactly one place where "what may be read" is decided. | Client | Ingress | Certificate trust scope | |---|---|---| | Azure CLI (`az`) | RPC broker: an agent-side wrapper forwards argv to a sidecar that runs the real `az`, pointed at the policy engine with `--organization https:///` | the `az` process only | -| Azure DevOps MCP | container attached only to an internal network where `dev.azure.com` is a DNS alias for the policy engine | the MCP container only | +| Azure DevOps MCP | container on a Docker `--internal` network, where `dev.azure.com` is redirected at the policy engine via `--add-host`. Internal is load-bearing: a normal bridge has outbound NAT and would leave a direct route past the engine | the MCP container only | | Hand-rolled `curl` / SDK calls from the Agent | none — Squid denies the protected hosts | none; fails closed | **Why `az` uses the broker.** `az` accepts an arbitrary base URL, so it can be @@ -439,6 +439,11 @@ re-derived. | **The real container starts from exactly that document** | Piping the captured material into `node:20-slim` with the generated policy mounted brought the engine up on both ingresses (`0.0.0.0:11080` proxy, `0.0.0.0:443` direct TLS) and it published its interception CA to the shared host directory | | **The engine polices live traffic through `--add-host`** | A separate container redirected at the engine's IP, trusting only the published CA with verification on, got: allowed discovery → `502 upstream-failed` (policy allowed; egress attempted **only** via the configured Squid, absent locally); a `repos` route outside the granted capabilities → `403 unknown-route`; `/_apis/distributedtask/variablegroups` → `403` always-denied route family. No denial reached an upstream | | `--public-ca-file` is an **output**, not a trust store | It is where the engine *writes* its interception CA for clients. Pointing it at `/etc/ssl/certs/ca-certificates.crt` failed `EROFS`. Upstream verification instead uses Node's bundled roots — `node:20-slim` ships **no** OS trust store but carries 144 roots — so nothing needs mounting for it | +| **A shared bridge is not a boundary; `--internal` is** | A container on a normal user-defined bridge reached `https://example.com` (status 200) through Docker's outbound NAT. On an `--internal` bridge the same request failed, while the container still routed to its peers. A dual-homed container kept full egress via its *second* network. This is why the MCP network is created `--internal`: otherwise the MCP keeps a direct route to every Azure DevOps host the redirect does not override, and the engine polices one hostname rather than the boundary. It corrects an earlier claim in this document that AWF's `DOCKER-USER` scoping alone left the MCP with "no unpoliced route out" | +| **The full chain works end to end, and the engine injects the credential** | With a fake Squid and a fake Azure DevOps behind it, a client on the internal network got `200` and real JSON. Every request reaching the upstream carried an `Authorization` header, it was the **injected canary**, and the sentinel the client held **never** appeared upstream | +| **Denied requests never reach the upstream** | Against the same live chain, `distributedtask/variablegroups`, `serviceendpoint`, a `POST` write, and an unknown route all returned `403` with distinct reasons, and the upstream request count was **unchanged** across all four | +| **The MCP cannot see the credential** | Scanning the MCP container's environment, every mount, `/tmp`, and the process table for the canary found **0 occurrences**; `ADO_MCP_AUTH_TOKEN` held the sentinel | +| **The MCP starts with no registry access** | On the internal network `npm view` failed `EAI_AGAIN`, yet the MCP completed an `initialize` handshake from the mounted package | Three harnesses produce this evidence and should become conformance tests: diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 379304094..e902e48e9 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -3267,8 +3267,17 @@ fn prepare_ado_proxy_clients_step() -> BashStep { "set -euo pipefail\n\ \n\ # Network shared by the policy engine and the Azure DevOps MCP.\n\ + #\n\ + # `--internal` is load-bearing, not tidiness. A normal user-defined\n\ + # bridge has outbound NAT, so the MCP would keep a direct route to the\n\ + # internet — including Azure DevOps hosts that are not redirected —\n\ + # and the engine would police only the one hostname we happen to\n\ + # override. Measured: a container on a normal bridge reaches the\n\ + # internet; on an internal bridge it cannot, while still reaching its\n\ + # peers. The engine keeps its own egress because AWF dual-homes it\n\ + # onto awf-net, where Squid lives.\n\ if ! docker network inspect {ADO_PROXY_NETWORK_NAME} >/dev/null 2>&1; then\n \ - docker network create {ADO_PROXY_NETWORK_NAME}\n\ + docker network create --internal {ADO_PROXY_NETWORK_NAME}\n\ fi\n\ \n\ # Install the MCP on the runner and stage it for mounting. The\n\ @@ -4309,7 +4318,9 @@ mod tests { // address, which does not exist until the engine is running. Starting // MCPG first would leave the redirect unresolvable. let script = prepare_ado_proxy_clients_step().script; - assert!(script.contains(&format!("docker network create {ADO_PROXY_NETWORK_NAME}"))); + assert!(script.contains(&format!( + "docker network create --internal {ADO_PROXY_NETWORK_NAME}" + ))); assert!( script.contains(&format!("{ADO_MCP_PACKAGE}@{ADO_MCP_VERSION}")), "the MCP package must be pinned, not floating: {script}" @@ -4324,6 +4335,20 @@ mod tests { ); } + #[test] + fn the_mcp_network_has_no_route_to_the_internet() { + // Measured, not assumed: a container on a normal user-defined bridge + // reaches the internet through Docker's outbound NAT. Without + // `--internal` the MCP would keep a direct route to every Azure DevOps + // host the redirect does not override, and the engine would police one + // hostname rather than the boundary. + let script = prepare_ado_proxy_clients_step().script; + assert!( + script.contains("--internal"), + "the MCP must not be able to route past the policy engine: {script}" + ); + } + #[test] fn the_default_capability_set_is_the_whole_catalog() { // Deliberately broad within a narrow boundary: every catalogued diff --git a/src/compile/common.rs b/src/compile/common.rs index 0dd56bc2e..c2f4aa475 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1635,10 +1635,12 @@ pub const ADO_MCP_TOKEN_SENTINEL: &str = "ado-proxy-injects-the-real-credential" /// Docker network shared by the policy engine and the Azure DevOps MCP. /// -/// Separate from AWF's `awf-net`: AWF's `DOCKER-USER` rules are scoped to its -/// own bridge (`-i `), so they do not filter this network. That is -/// what lets the MCP reach the engine here while still having no unpoliced -/// route out — its only reachable peer is the engine. +/// Created `--internal`: a normal user-defined bridge has outbound NAT, which +/// would leave the MCP a direct route to the internet and reduce the engine to +/// policing only the single hostname the redirect overrides. Internal networks +/// still route between their own members, so the MCP reaches the engine and +/// nothing else. The engine keeps its own egress because AWF dual-homes it +/// onto `awf-net`, where Squid lives. pub const ADO_PROXY_NETWORK_NAME: &str = "ado-aw-proxy-net"; /// Path the public interception CA is mounted at inside client containers. From 156ea0073e8ff3f231f9ec0a35efb07f5a96f192 Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 3 Aug 2026 15:24:05 +0100 Subject: [PATCH 17/42] feat(ado-proxy): add the az wrapper and fix the CA for strict verifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the wrapper the agent gets in place of stock az. It sets three environment variables and execs the real binary; it does not rewrite arguments. Not wired into the sandbox yet — that is the next change. Earlier drafts rewrote --organization to a broker hostname. Dropping that is a simplification, not a shortcut. The organization can also arrive via --org, AZURE_DEVOPS_ORG, or a stored z devops configure --defaults value, so rewriting means enumerating every form and staying correct as the CLI evolves, and a missed form silently escapes the policy. A non-canonical hostname is also unusable: interception leaves and catalogued routes are both keyed to dev.azure.com, so it would fail SNI selection and match no operation. Pointing HTTPS_PROXY at the engine puts the redirect below the CLI's own configuration, so every form works without the wrapper interpreting any of them. Verified with real az 2.86 against a live engine: z devops project list --organization https://dev.azure.com/contoso completed the CONNECT, verified the intercepted certificate from REQUESTS_CA_BUNDLE alone, and the request arriving upstream carried the INJECTED bearer while the sentinel the CLI held never appeared there. The generated wrapper was then run on Linux: allowed groups pass through with arguments untouched, az storage is refused with an actionable message, --version still works, and the PATH scan skips the wrapper's own directory so it cannot re-enter itself. That first az run also exposed a real defect in the certificate the engine publishes. The CA declared basicConstraints pathlen:0 without keyUsage keyCertSign, which Node accepts and OpenSSL 3 rejects outright ("Path length given without key usage keyCertSign"). Every client exercised so far had been Node, so nothing caught it; az failed CERTIFICATE_VERIFY_FAILED. The CA now declares keyUsage=critical,keyCertSign,cRLSign, with a regression test, after which az completed TLS and reached policy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 31 +++-- src/compile/agentic_pipeline.rs | 17 ++- src/compile/az_wrapper.rs | 226 ++++++++++++++++++++++++++++++++ src/compile/common.rs | 24 ++++ src/compile/mod.rs | 1 + 5 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 src/compile/az_wrapper.rs diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index 99917db6b..f0d06ae06 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -83,19 +83,28 @@ there is exactly one place where "what may be read" is decided. | Client | Ingress | Certificate trust scope | |---|---|---| -| Azure CLI (`az`) | RPC broker: an agent-side wrapper forwards argv to a sidecar that runs the real `az`, pointed at the policy engine with `--organization https:///` | the `az` process only | +| Azure CLI (`az`) | an agent-side wrapper sets `HTTPS_PROXY` at the engine's `CONNECT` port and execs stock `az`, which keeps the canonical `dev.azure.com` URL | the `az` process only | | Azure DevOps MCP | container on a Docker `--internal` network, where `dev.azure.com` is redirected at the policy engine via `--add-host`. Internal is load-bearing: a normal bridge has outbound NAT and would leave a direct route past the engine | the MCP container only | | Hand-rolled `curl` / SDK calls from the Agent | none — Squid denies the protected hosts | none; fails closed | -**Why `az` uses the broker.** `az` accepts an arbitrary base URL, so it can be -*told* to talk to the policy engine rather than deceived about a public -hostname. Verified: pointed at `https://localhost:/`, `az devops -project list` issued `OPTIONS //_apis` followed by `GET -//_apis/projects` to that endpoint, with TLS verified against a -certificate trusted only by that process. No public hostname is impersonated -and no CA is installed anywhere. This mirrors AWF's existing `cli-proxy` -sidecar, which relocates `gh` into a sidecar holding `GH_TOKEN` and points it -at a guard via `GH_HOST`. +**Why `az` needs no argument rewriting.** Earlier drafts pointed `az` at the +engine with `--organization https:///`. That works — `az` accepts +an arbitrary base URL — but it is both harder and weaker than redirecting the +transport. Harder, because the organization can also arrive via `--org`, +`AZURE_DEVOPS_ORG`, or a stored `az devops configure --defaults` value, so the +wrapper would have to enumerate every form and stay correct as the CLI evolves; +a missed form silently escapes the policy. Weaker, because a non-canonical +hostname has no interception leaf and matches no catalogued route — both are +keyed to `dev.azure.com`. + +Setting `HTTPS_PROXY` instead puts the redirect *below* the CLI's own +configuration, so every form resolves to the same canonical host and that host +is what gets intercepted. Verified with real `az` 2.86 against the live engine: +`az devops project list --organization https://dev.azure.com/contoso` completed +the `CONNECT`, verified the intercepted certificate from `REQUESTS_CA_BUNDLE` +alone, and the request arriving upstream carried the injected bearer rather +than the sentinel the CLI held. No public hostname is impersonated to anything +but this one process, and no CA is installed system-wide. **Why the MCP uses a DNS alias.** It cannot be told. `@azure-devops/mcp` derives its base URL as `"https://dev.azure.com/" + orgName` with no override, @@ -444,6 +453,8 @@ re-derived. | **Denied requests never reach the upstream** | Against the same live chain, `distributedtask/variablegroups`, `serviceendpoint`, a `POST` write, and an unknown route all returned `403` with distinct reasons, and the upstream request count was **unchanged** across all four | | **The MCP cannot see the credential** | Scanning the MCP container's environment, every mount, `/tmp`, and the process table for the canary found **0 occurrences**; `ADO_MCP_AUTH_TOKEN` held the sentinel | | **The MCP starts with no registry access** | On the internal network `npm view` failed `EAI_AGAIN`, yet the MCP completed an `initialize` handshake from the mounted package | +| **Stock `az` needs no argument rewriting at all** | With `HTTPS_PROXY` pointed at the engine's `CONNECT` port, `REQUESTS_CA_BUNDLE` at the published CA, and a sentinel PAT, real `az` 2.86 ran `az devops project list --organization https://dev.azure.com/contoso` straight through the engine. The request arriving upstream (`OPTIONS /contoso/_apis`) carried the **injected** bearer; the sentinel never appeared there. Because the redirect happens below the CLI's own configuration, `--organization`, `--org`, `AZURE_DEVOPS_ORG` and stored defaults all work without the wrapper interpreting any of them | +| **A `pathlen` CA without `keyCertSign` breaks strict verifiers** | The first `az` run failed `CERTIFICATE_VERIFY_FAILED … Path length given without key usage keyCertSign`. Every Node client had accepted the same CA — only Python's `requests`, which verifies strictly, rejected it. The CA now declares `keyUsage=critical,keyCertSign,cRLSign`, after which `az` completed TLS and reached policy | Three harnesses produce this evidence and should become conformance tests: diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index e902e48e9..2fa5b9336 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -3398,7 +3398,8 @@ fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { openssl req -x509 -newkey rsa:2048 -nodes -days 2 \\\n \ -subj \"/CN=ado-aw ado-proxy interception CA\" \\\n \ -keyout \"$PROXY_DIR/ca.key\" -out \"$PROXY_DIR/ca.pem\" \\\n \ - -addext \"basicConstraints=critical,CA:TRUE,pathlen:0\" 2>/dev/null\n\ + -addext \"basicConstraints=critical,CA:TRUE,pathlen:0\" \\\n \ + -addext \"keyUsage=critical,keyCertSign,cRLSign\" 2>/dev/null\n\ for PROXY_HOST in {leaf_loop}; do\n \ printf 'basicConstraints=CA:FALSE\\nkeyUsage=critical,digitalSignature,keyEncipherment\\nextendedKeyUsage=serverAuth\\nsubjectAltName=DNS:%s\\n' \"$PROXY_HOST\" > \"$PROXY_DIR/leaf.ext\"\n \ openssl req -new -newkey rsa:2048 -nodes -subj \"/CN=$PROXY_HOST\" \\\n \ @@ -4335,6 +4336,20 @@ mod tests { ); } + #[test] + fn the_interception_ca_is_usable_by_strict_verifiers() { + // `pathlen` without `keyCertSign` is accepted by Node but rejected by + // OpenSSL 3 with "Path length given without key usage keyCertSign". + // Real `az` hit exactly that: Python's requests verified the chain + // strictly and refused, while every Node client had been happy. The + // key usage must therefore be declared explicitly. + let script = start_ado_proxy_step(&[]).script; + assert!( + script.contains("keyUsage=critical,keyCertSign,cRLSign"), + "the CA must declare keyCertSign or strict verifiers reject it: {script}" + ); + } + #[test] fn the_mcp_network_has_no_route_to_the_internet() { // Measured, not assumed: a container on a normal user-defined bridge diff --git a/src/compile/az_wrapper.rs b/src/compile/az_wrapper.rs new file mode 100644 index 000000000..05a21c54b --- /dev/null +++ b/src/compile/az_wrapper.rs @@ -0,0 +1,226 @@ +//! The `az` wrapper installed into the agent sandbox. +//! +//! The agent runs stock `az`. It is *redirected*, not rewritten: the wrapper +//! sets three environment variables and execs the real binary. +//! +//! # Why environment rather than argument rewriting +//! +//! An earlier design rewrote `--organization` to point at a broker hostname. +//! That was both harder and weaker: +//! +//! - the organization can be given as `--organization`, `--org`, +//! `AZURE_DEVOPS_ORG`, or a stored `az devops configure --defaults` value, +//! so rewriting means enumerating every form and staying correct as the CLI +//! evolves — a miss silently escapes the policy; +//! - a non-canonical hostname has no interception leaf and matches no +//! catalogued route, since both are keyed to `dev.azure.com`. +//! +//! Pointing `HTTPS_PROXY` at the engine instead makes every form work +//! identically, because the redirect happens below the CLI's own +//! configuration. Verified with real `az` 2.86: `az devops project list +//! --organization https://dev.azure.com/contoso` reached the engine through +//! `CONNECT`, verified the intercepted certificate, and the request arriving +//! upstream carried the *injected* bearer while the sentinel the CLI held +//! never appeared there. +//! +//! # Scope of trust +//! +//! `REQUESTS_CA_BUNDLE` is set for this process only. Python's `requests` +//! bundles its own `certifi` roots and ignores the OS trust store, so a +//! system-wide install would not help `az` anyway — and per-process trust +//! keeps the interception certificate off every other client. Trust here is an +//! *availability* control: enforcement comes from routing, so a client that +//! declines the certificate fails closed rather than escaping the policy. + +use super::common::{AZ_ALLOWED_GROUPS, AZ_WRAPPER_CA_PATH, AZ_WRAPPER_DIR}; + +/// Render the wrapper script. +/// +/// `engine_host` is the policy engine's container name, which AWF registers in +/// the agent's `/etc/hosts` when it attaches the container to the internal +/// network. +#[allow(dead_code)] +pub fn render_az_wrapper(engine_host: &str, connect_port: u16, sentinel: &str) -> String { + let allowed_list = AZ_ALLOWED_GROUPS.join(" "); + let allowed_display = AZ_ALLOWED_GROUPS.join(", "); + + format!( + r##"#!/bin/sh +# Azure CLI wrapper installed by ado-aw. +# +# The agent has no Azure DevOps credential. This wrapper points `az` at the +# ado-proxy policy engine, which holds the credential and serves only the +# operations in its versioned read-only catalog. +# +# Generated — edits here are overwritten on every run. +set -eu + +# Refuse command groups whose traffic the policy does not describe. They would +# otherwise fail somewhere far less legible: no Azure credential is present, so +# `az vm` or `az storage` would surface an authentication error that looks like +# a broken pipeline rather than a deliberate boundary. +AZ_GROUP="${{1:-}}" +case " {allowed_list} " in + *" $AZ_GROUP "*) ;; + *) + case "$AZ_GROUP" in + ""|-h|--help|--version|-v) + # Informational invocations touch no network; let them through. + ;; + *) + echo "ado-aw: 'az $AZ_GROUP' is not available to this agent." >&2 + echo "" >&2 + echo "This workflow reaches Azure DevOps through a policy proxy that" >&2 + echo "serves read-only operations for the current project. Available" >&2 + echo "command groups: {allowed_display}." >&2 + echo "" >&2 + echo "To act outside that boundary, use a safe output instead:" >&2 + echo "https://github.com/githubnext/ado-aw/blob/main/docs/safe-outputs.md" >&2 + exit 1 + ;; + esac + ;; +esac + +# Route Azure DevOps traffic through the policy engine. This is what makes the +# redirect independent of how the organization was specified — --organization, +# --org, AZURE_DEVOPS_ORG and stored defaults all resolve to the same canonical +# host, and that host is what gets intercepted. +HTTPS_PROXY="http://{engine_host}:{connect_port}" +export HTTPS_PROXY +https_proxy="$HTTPS_PROXY" +export https_proxy + +# Trust the engine's interception certificate for this process only. Python's +# requests ignores the OS trust store, so this variable — not the system CA +# bundle — is what `az` actually consults. +REQUESTS_CA_BUNDLE="{AZ_WRAPPER_CA_PATH}" +export REQUESTS_CA_BUNDLE + +# A non-secret placeholder. `az` requires *some* credential to attempt a call; +# the engine strips whatever the client sent and attaches the real bearer only +# after a complete allow decision. +AZURE_DEVOPS_EXT_PAT="{sentinel}" +export AZURE_DEVOPS_EXT_PAT + +# Locate the real binary. `exec az` would re-enter this wrapper, because the +# wrapper's own directory is prepended to PATH. +AZ_REAL="" +IFS=: +for dir in $PATH; do + case "$dir" in + ""|{AZ_WRAPPER_DIR}) continue ;; + esac + if [ -x "$dir/az" ]; then + AZ_REAL="$dir/az" + break + fi +done +unset IFS + +if [ -z "$AZ_REAL" ]; then + echo "ado-aw: the Azure CLI is not installed on this image." >&2 + exit 127 +fi + +exec "$AZ_REAL" "$@" +"## + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile::common::ADO_MCP_TOKEN_SENTINEL; + + fn wrapper() -> String { + render_az_wrapper("awmg-ado-proxy", 11080, ADO_MCP_TOKEN_SENTINEL) + } + + #[test] + fn routes_azure_devops_traffic_through_the_engine() { + let script = wrapper(); + assert!(script.contains("HTTPS_PROXY=\"http://awmg-ado-proxy:11080\"")); + assert!( + script.contains("export HTTPS_PROXY") && script.contains("export https_proxy"), + "both spellings matter: tooling reads one or the other" + ); + } + + #[test] + fn trusts_the_interception_certificate_for_this_process_only() { + let script = wrapper(); + assert!(script.contains(&format!("REQUESTS_CA_BUNDLE=\"{AZ_WRAPPER_CA_PATH}\""))); + // A system-wide install would not help: Python's requests uses its own + // certifi bundle. It would also widen trust beyond this one client. + assert!(!script.contains("update-ca-certificates")); + assert!(!script.contains("/usr/local/share/ca-certificates")); + } + + #[test] + fn carries_a_sentinel_rather_than_a_credential() { + let script = wrapper(); + assert!(script.contains(&format!( + "AZURE_DEVOPS_EXT_PAT=\"{ADO_MCP_TOKEN_SENTINEL}\"" + ))); + assert!( + !script.contains("SC_READ_TOKEN") && !script.contains("System.AccessToken"), + "no real credential may appear in an agent-readable file: {script}" + ); + } + + #[test] + fn does_not_rewrite_how_the_organization_was_specified() { + // The redirect happens below the CLI's configuration, so every form + // works without the wrapper having to know about any of them. Touching + // them would reintroduce the enumeration problem this design avoids. + // + // Comments are stripped first: the rationale above legitimately names + // these flags, and asserting on prose would make the guard vacuous. + let script = wrapper(); + let code: String = script + .lines() + .filter(|line| !line.trim_start().starts_with('#')) + .collect::>() + .join("\n"); + for form in ["--organization", "--org", "AZURE_DEVOPS_ORG"] { + assert!( + !code.contains(form), + "the wrapper must not interpret {form}: {code}" + ); + } + } + + #[test] + fn refuses_command_groups_outside_the_policed_surface() { + let script = wrapper(); + for group in AZ_ALLOWED_GROUPS { + assert!( + script.contains(group), + "{group} is catalogued and must be permitted" + ); + } + assert!(script.contains("is not available to this agent")); + // An actionable message: a bare denial invites the agent to retry the + // same call, or to conclude the pipeline is broken. + assert!(script.contains("safe-outputs.md")); + } + + #[test] + fn execs_the_real_binary_without_re_entering_itself() { + let script = wrapper(); + // The wrapper directory is prepended to PATH, so a bare `exec az` + // would loop until the process ran out of file descriptors. + assert!(script.contains(&format!("\"\"|{AZ_WRAPPER_DIR}) continue"))); + assert!(script.contains("exec \"$AZ_REAL\" \"$@\"")); + } + + #[test] + fn reports_a_missing_azure_cli_rather_than_failing_obscurely() { + let script = wrapper(); + assert!(script.contains("the Azure CLI is not installed")); + assert!(script.contains("exit 127")); + } + +} + diff --git a/src/compile/common.rs b/src/compile/common.rs index c2f4aa475..2b22418ec 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1646,6 +1646,30 @@ pub const ADO_PROXY_NETWORK_NAME: &str = "ado-aw-proxy-net"; /// Path the public interception CA is mounted at inside client containers. pub const ADO_MCP_CA_MOUNT: &str = "/etc/ado-proxy/ca.pem"; +/// Directory the generated `az` wrapper is installed into inside the sandbox. +/// +/// Separate from the ado-script bundle directory because it is prepended to +/// `PATH`: anything placed here shadows a real executable of the same name. +#[allow(dead_code)] +pub const AZ_WRAPPER_DIR: &str = "/tmp/ado-aw-lib"; + +/// Full path of the generated `az` wrapper. +#[allow(dead_code)] +pub const AZ_WRAPPER_PATH: &str = "/tmp/ado-aw-lib/az"; + +/// Path the public interception CA is staged at for the `az` wrapper. +#[allow(dead_code)] +pub const AZ_WRAPPER_CA_PATH: &str = "/tmp/ado-aw-lib/ado-proxy-ca.pem"; + +/// Azure CLI command groups the wrapper permits. +/// +/// These are the groups whose traffic the catalog actually describes. Anything +/// else — `az vm`, `az storage`, `az ad` — would leave the policed surface, so +/// the wrapper refuses it with an explanation rather than letting it fail +/// somewhere less legible. +#[allow(dead_code)] +pub const AZ_ALLOWED_GROUPS: &[&str] = &["devops", "repos", "pipelines", "boards", "artifacts"]; + /// Runner-side path of the CA certificate the policy engine publishes. pub const ADO_PROXY_PUBLIC_CA_HOST_PATH: &str = "/tmp/gh-aw/ado-proxy/ado-proxy-ca.pem"; diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 94848ee9f..c45951620 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -7,6 +7,7 @@ //! - **1ES**: Integration with 1ES Pipeline Templates for SDL compliance mod common; +pub mod az_wrapper; pub(crate) use common::resolve_repos; pub(crate) mod ado_bundle; pub(crate) mod agentic_pipeline; From 8019fd831f1b45fd759c9032d1f7824518d20e3e Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 3 Aug 2026 16:24:32 +0100 Subject: [PATCH 18/42] feat(ado-proxy): install the az wrapper into the agent sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the wrapper in: an agent-prepare step writes it to /tmp/ado-aw-lib/az and the Azure CLI extension prepends that directory to the sandbox PATH, so the agent's z resolves to the wrapper and the wrapper execs the real binary. No mount is needed. AWF already bind-mounts the runner's /tmp into the agent chroot — the same mechanism that delivers the agent prompt and the Copilot binary — so writing the file makes it visible at the same path inside. The PATH prepend is still required, because the chroot's /usr/local/bin is not the container's and only PATH order decides which binary the agent invokes; this mirrors how AWF installs its own gh wrapper. The engine now publishes its interception certificate directly into that directory rather than a separate one, so the wrapper reads and the MCP mounts the same file and no client can trust a stale copy. Only the certificate is published; the private key is still destroyed by the step that starts the engine. Installation is gated on the existing AW_AZ_MOUNTS detection signal: with no az on the runner there is nothing for the wrapper to exec, and shadowing a missing binary would turn a clear "command not found" into a confusing wrapper error. A shared ado_proxy_enabled() predicate now backs both the pipeline builder and this extension. Two independent checks could drift into installing a wrapper that points at an engine which was never started, or starting an engine that nothing routes through. Verified against real compiled output rather than the generator: the install step was extracted from the emitted lock file and run on Linux, producing a mode-755 file whose shebang sits at column 0. Invoking it, an allowed group passed through with arguments untouched and all three environment variables set, and z vm was refused with the actionable message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- src/compile/agentic_pipeline.rs | 24 +++-- src/compile/common.rs | 22 +++- src/compile/extensions/azure_cli.rs | 149 +++++++++++++++++++++++++++- 3 files changed, 178 insertions(+), 17 deletions(-) diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 2fa5b9336..3f290c353 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -67,8 +67,8 @@ use super::common::PerJobPools; use super::common::{ self, ADO_BUILD_ID_SUFFIX, ADO_MCP_HOST_NODE_MODULES, ADO_MCP_PACKAGE, ADO_MCP_VERSION, ADO_PROXY_CONTAINER_NAME, ADO_PROXY_IMAGE, ADO_PROXY_LISTEN_PORT, ADO_PROXY_NETWORK_NAME, - ADO_PROXY_PUBLIC_CA_HOST_PATH, ADO_PROXY_TLS_PORT, AWF_SQUID_URL, AWF_VERSION, HEADER_MARKER, - MCPG_CONTAINER_NAME, MCPG_DOMAIN, MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, image_ref, + ADO_PROXY_PUBLIC_CA_HOST_PATH, ADO_PROXY_TLS_PORT, AWF_SQUID_URL, AWF_VERSION, AZ_WRAPPER_DIR, + HEADER_MARKER, MCPG_CONTAINER_NAME, MCPG_DOMAIN, MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, image_ref, }; use super::extensions::ado_script as paths; use crate::ado_proxy::catalog::{self, Capability}; @@ -3411,10 +3411,11 @@ fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { \n\ # The proxy publishes its own interception CA certificate for clients\n\ # to trust. It goes under /tmp deliberately: AWF mounts /tmp into the\n\ - # agent chroot, and this is a public certificate that az and the ADO\n\ - # MCP must be able to read. The matching private key never leaves\n\ - # $PROXY_DIR and is destroyed below.\n\ - mkdir -p /tmp/gh-aw/ado-proxy\n\ + # agent chroot, so this one file is what the az wrapper reads and what\n\ + # the MCP container mounts. Publishing once means no client can trust\n\ + # a stale copy. The matching private key never leaves $PROXY_DIR and\n\ + # is destroyed below.\n\ + mkdir -p {az_wrapper_dir}\n\ echo \"##vso[task.setvariable variable=ADO_PROXY_CA_FILE]{ca_host_path}\"\n\ \n\ # Build the material document. jq assembles it so that a value\n\ @@ -3441,7 +3442,7 @@ fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { --network {ADO_PROXY_NETWORK_NAME} \\\n \ -v \"{ado_proxy_path}:/app/ado-proxy.js:ro\" \\\n \ -v \"$PROXY_DIR/policy:/etc/ado-proxy:ro\" \\\n \ - -v /tmp/gh-aw/ado-proxy:/var/lib/ado-proxy \\\n \ + -v /tmp/ado-aw-lib:/var/lib/ado-proxy \\\n \ -v /tmp/gh-aw/ado-proxy-logs:/var/log/ado-proxy \\\n \ {ado_proxy_image} \\\n \ node /app/ado-proxy.js \\\n \ @@ -3480,6 +3481,7 @@ fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { echo \"##vso[task.setvariable variable=ADO_PROXY_IP]$ADO_PROXY_IP\"\n", ado_proxy_path = paths::ADO_PROXY_PATH, ca_host_path = ADO_PROXY_PUBLIC_CA_HOST_PATH, + az_wrapper_dir = AZ_WRAPPER_DIR, ado_proxy_image = ADO_PROXY_IMAGE, squid_url = AWF_SQUID_URL, listen_port = ADO_PROXY_LISTEN_PORT, @@ -4518,11 +4520,11 @@ mod tests { // CA there so clients can trust it. It must land somewhere the agent // can read (AWF mounts /tmp into the chroot) — unlike the signing key. assert!(script.contains("--public-ca-file /var/lib/ado-proxy/ado-proxy-ca.pem")); - assert!(script.contains("-v /tmp/gh-aw/ado-proxy:/var/lib/ado-proxy")); + assert!(script.contains(&format!("-v {AZ_WRAPPER_DIR}:/var/lib/ado-proxy"))); assert!( - script.contains( - "##vso[task.setvariable variable=ADO_PROXY_CA_FILE]/tmp/gh-aw/ado-proxy/ado-proxy-ca.pem" - ), + script.contains(&format!( + "##vso[task.setvariable variable=ADO_PROXY_CA_FILE]{ADO_PROXY_PUBLIC_CA_HOST_PATH}" + )), "clients need the published certificate's path: {script}" ); assert!( diff --git a/src/compile/common.rs b/src/compile/common.rs index 2b22418ec..11bbc32fb 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1646,6 +1646,20 @@ pub const ADO_PROXY_NETWORK_NAME: &str = "ado-aw-proxy-net"; /// Path the public interception CA is mounted at inside client containers. pub const ADO_MCP_CA_MOUNT: &str = "/etc/ado-proxy/ca.pem"; +/// Whether this workflow routes Azure DevOps access through the policy engine. +/// +/// Enabling `tools.azure-devops` is what pulls in the engine: the MCP is +/// redirected at it and the `az` wrapper points at it. Both the pipeline +/// builder and the Azure CLI extension need this answer and must not disagree — +/// a mismatch would either install a wrapper pointing at an engine that was +/// never started, or start an engine that nothing routes through. +pub fn ado_proxy_enabled(front_matter: &FrontMatter) -> bool { + front_matter + .tools + .as_ref() + .is_some_and(|tools| tools.azure_devops.is_some()) +} + /// Directory the generated `az` wrapper is installed into inside the sandbox. /// /// Separate from the ado-script bundle directory because it is prepended to @@ -1671,7 +1685,13 @@ pub const AZ_WRAPPER_CA_PATH: &str = "/tmp/ado-aw-lib/ado-proxy-ca.pem"; pub const AZ_ALLOWED_GROUPS: &[&str] = &["devops", "repos", "pipelines", "boards", "artifacts"]; /// Runner-side path of the CA certificate the policy engine publishes. -pub const ADO_PROXY_PUBLIC_CA_HOST_PATH: &str = "/tmp/gh-aw/ado-proxy/ado-proxy-ca.pem"; +/// +/// Deliberately inside [`AZ_WRAPPER_DIR`]: AWF mounts `/tmp` into the agent +/// chroot, so this single published file is what the `az` wrapper reads *and* +/// what the MCP container mounts. Publishing once removes the possibility of a +/// client trusting a stale copy. Only the certificate goes here — the matching +/// private key is destroyed by the step that starts the engine. +pub const ADO_PROXY_PUBLIC_CA_HOST_PATH: &str = "/tmp/ado-aw-lib/ado-proxy-ca.pem"; /// Default entrypoint args for the Azure DevOps MCP npm package. pub const ADO_MCP_PACKAGE: &str = "@azure-devops/mcp"; diff --git a/src/compile/extensions/azure_cli.rs b/src/compile/extensions/azure_cli.rs index 4e7dccaf4..3958cb5fc 100644 --- a/src/compile/extensions/azure_cli.rs +++ b/src/compile/extensions/azure_cli.rs @@ -1,4 +1,8 @@ use super::{CompileContext, CompilerExtension, Declarations, ExtensionPhase}; +use crate::compile::common::{ + ADO_MCP_TOKEN_SENTINEL, ADO_PROXY_CONTAINER_NAME, ADO_PROXY_LISTEN_PORT, AZ_WRAPPER_DIR, + AZ_WRAPPER_PATH, +}; use crate::compile::ir::condition::{Condition, Expr}; use crate::compile::ir::step::{BashStep, Step}; @@ -75,7 +79,17 @@ impl CompilerExtension for AzureCliExtension { /// step uses [`Condition::Ne`] of that pipeline variable against /// the empty-string literal — same wire shape as today's /// `condition: ne(variables['AW_AZ_MOUNTS'], '')`. - fn declarations(&self, _ctx: &CompileContext) -> anyhow::Result { + fn declarations(&self, ctx: &CompileContext) -> anyhow::Result { + let proxied = crate::compile::common::ado_proxy_enabled(ctx.front_matter); + + let mut agent_prepare_steps = vec![Step::Bash(detection_bash_step())]; + if proxied { + // Installed before the prompt is appended so the advisory and the + // wrapper cannot describe different worlds. + agent_prepare_steps.push(Step::Bash(install_az_wrapper_step())); + } + agent_prepare_steps.push(Step::Bash(prompt_append_bash_step())); + Ok(Declarations { network_hosts: vec![ // OAuth + sign-in @@ -89,15 +103,55 @@ impl CompilerExtension for AzureCliExtension { "aka.ms".to_string(), ], bash_commands: vec!["az".to_string()], - agent_prepare_steps: vec![ - Step::Bash(detection_bash_step()), - Step::Bash(prompt_append_bash_step()), - ], + agent_prepare_steps, + // Shadow the real `az` with the wrapper. Both the file and this + // prepend are needed: the agent runs in a chroot, so the container's + // /usr/local/bin is not the chroot's, and only PATH order decides + // which binary the agent actually invokes. AWF installs its own `gh` + // wrapper the same way. + awf_path_prepends: if proxied { + vec![AZ_WRAPPER_DIR.to_string()] + } else { + Vec::new() + }, ..Declarations::default() }) } } +/// Install the generated `az` wrapper into the sandbox. +/// +/// No mount is required: AWF bind-mounts the runner's `/tmp` into the agent +/// chroot, which is the same mechanism that delivers the agent prompt and the +/// Copilot binary. Writing the file here therefore makes it visible to the +/// agent at the same path. +/// +/// Gated on the same `AW_AZ_MOUNTS` signal as the prompt advisory: with no +/// `az` on the runner there is nothing for the wrapper to exec, and shadowing a +/// missing binary would turn a clear "command not found" into a confusing +/// wrapper error. +fn install_az_wrapper_step() -> BashStep { + let wrapper = crate::compile::az_wrapper::render_az_wrapper( + ADO_PROXY_CONTAINER_NAME, + ADO_PROXY_LISTEN_PORT, + ADO_MCP_TOKEN_SENTINEL, + ); + // Indent the body for the heredoc without altering its content. + let script = format!( + "set -eo pipefail\n\ + mkdir -p {AZ_WRAPPER_DIR}\n\ + cat > '{AZ_WRAPPER_PATH}' << 'ADO_AW_AZ_WRAPPER_EOF'\n\ + {wrapper}\n\ + ADO_AW_AZ_WRAPPER_EOF\n\ + chmod 755 '{AZ_WRAPPER_PATH}'\n\ + echo \"az wrapper installed at {AZ_WRAPPER_PATH}\"\n" + ); + BashStep::new("Install az wrapper (ado-proxy)", script).with_condition(Condition::Ne( + Expr::Variable("AW_AZ_MOUNTS".to_string()), + Expr::Literal(String::new()), + )) +} + /// Detect azure-cli on the host and set the `AW_AZ_MOUNTS` pipeline /// variable for the later AWF invocation. fn detection_bash_step() -> BashStep { @@ -146,6 +200,91 @@ mod tests { serde_yaml::from_str("name: t\ndescription: x\n").expect("front matter parses") } + /// Front matter that enables the Azure DevOps tool, which is what pulls in + /// the policy engine and therefore the wrapper. + fn fm_proxied() -> FrontMatter { + serde_yaml::from_str("name: t\ndescription: x\ntools:\n azure-devops:\n org: myorg\n") + .expect("front matter parses") + } + + fn wrapper_step(front_matter: &FrontMatter) -> Option { + let ctx = CompileContext::for_test(front_matter); + AzureCliExtension + .declarations(&ctx) + .unwrap() + .agent_prepare_steps + .into_iter() + .filter_map(|step| match step { + Step::Bash(b) if b.display_name.contains("az wrapper") => Some(b), + _ => None, + }) + .next() + } + + #[test] + fn the_wrapper_is_installed_only_when_traffic_is_policed() { + // Without the policy engine there is nothing to redirect to, and + // shadowing `az` would break it rather than contain it. + assert!(wrapper_step(&fm()).is_none()); + assert!(wrapper_step(&fm_proxied()).is_some()); + } + + #[test] + fn the_wrapper_directory_shadows_the_real_az() { + // The file alone is not enough: the agent runs in a chroot, so only + // PATH order decides which binary it actually invokes. + let plain = fm(); + let ctx_plain = CompileContext::for_test(&plain); + assert!( + AzureCliExtension + .declarations(&ctx_plain) + .unwrap() + .awf_path_prepends + .is_empty() + ); + + let proxied = fm_proxied(); + let ctx = CompileContext::for_test(&proxied); + assert_eq!( + AzureCliExtension + .declarations(&ctx) + .unwrap() + .awf_path_prepends, + vec![AZ_WRAPPER_DIR.to_string()] + ); + } + + #[test] + fn the_wrapper_install_is_gated_on_az_being_present() { + // With no `az` on the runner there is nothing for the wrapper to exec, + // and shadowing a missing binary turns a clear "command not found" + // into a confusing wrapper error. + let step = wrapper_step(&fm_proxied()).expect("wrapper step"); + assert_eq!( + step.condition, + Some(Condition::Ne( + Expr::Variable("AW_AZ_MOUNTS".to_string()), + Expr::Literal(String::new()), + )) + ); + } + + #[test] + fn the_installed_wrapper_is_executable_and_starts_with_a_shebang() { + let step = wrapper_step(&fm_proxied()).expect("wrapper step"); + assert!(step.script.contains(&format!("chmod 755 '{AZ_WRAPPER_PATH}'"))); + // The heredoc body must not be indented: a shebang preceded by + // whitespace is not a shebang, and the file would fail to exec. + assert!( + step.script.contains("ADO_AW_AZ_WRAPPER_EOF'\n#!/bin/sh"), + "the wrapper body must start at column 0: {}", + step.script + ); + // A quoted heredoc delimiter keeps the shell from expanding `$PATH`, + // `$@` and friends while writing the file. + assert!(step.script.contains("<< 'ADO_AW_AZ_WRAPPER_EOF'")); + } + fn agent_prepare_steps(ext: &AzureCliExtension, ctx: &CompileContext<'_>) -> Vec { ext.declarations(ctx).unwrap().agent_prepare_steps } From db31f8c2ef20a3b8f0adc69a249095f4188435b3 Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 3 Aug 2026 17:49:24 +0100 Subject: [PATCH 19/42] feat(ado-proxy): tell the agent what az can actually do, and allow az rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Azure CLI advisory still said z devops was not pre-authenticated. Under the policy engine that is wrong in the opposite direction, and a wrong prompt is not cosmetic: an agent told a command is unavailable will not try it, while one told it has access it lacks will retry a failing call or invent a workaround. The proxied text now states what genuinely works — read-only, current organization and project — and, just as importantly, that refusals are deliberate rather than a misconfiguration, so retrying or authenticating will not help. The unproxied text is unchanged and still claims nothing beyond "not pre-authenticated". Two defects surfaced while writing it. The wrapper permitted z artifacts, but no catalogued operation backs it, so the call passed the wrapper and was refused by the engine. The allow-list is now derived from the capabilities the policy actually grants, via a new Capability::az_command_group(), so the two cannot drift again; narrowing the policy narrows the wrapper with it. z rest was refused, which added no security and contradicted z devops invoke being allowed — both express arbitrary Azure DevOps REST. Measured against a live engine, z rest is fully contained by the catalog: a catalogued read returned real data with no login and no PAT, because the engine injects the credential; a denied route family returned 403 denied-route-family; and a POST returned 403 method-not-read. It is now permitted regardless of capabilities, since the catalog is what contains it. ado_proxy_capabilities moved to common.rs so the pipeline builder and the Azure CLI extension resolve capabilities from one place. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- src/ado_proxy/catalog.rs | 21 ++++++++ src/compile/agentic_pipeline.rs | 30 +++-------- src/compile/az_wrapper.rs | 81 +++++++++++++++++++++++++---- src/compile/common.rs | 46 +++++++++++++--- src/compile/extensions/azure_cli.rs | 52 +++++++++++++++--- 5 files changed, 184 insertions(+), 46 deletions(-) diff --git a/src/ado_proxy/catalog.rs b/src/ado_proxy/catalog.rs index d190892f1..0bc23f5d0 100644 --- a/src/ado_proxy/catalog.rs +++ b/src/ado_proxy/catalog.rs @@ -109,6 +109,27 @@ impl Capability { Self::Boards => "boards", } } + + /// The `az` command group this capability makes usable, if any. + /// + /// The `az` wrapper's allow-list is derived from this rather than + /// maintained by hand, so a capability the policy does not grant cannot be + /// advertised to the agent. Without it the two drift: `az artifacts` was + /// briefly permitted by the wrapper while no catalogued operation backed + /// it, so the call passed the wrapper and was refused by the engine. + /// + /// `Discovery` maps to nothing: it is the service-topology lookup every + /// client performs before its first real call, not a command group an + /// agent invokes. + pub const fn az_command_group(self) -> Option<&'static str> { + match self { + Self::Discovery => None, + Self::Core => Some("devops"), + Self::Repos => Some("repos"), + Self::Pipelines => Some("pipelines"), + Self::Boards => Some("boards"), + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 3f290c353..aef5a2551 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -1001,9 +1001,9 @@ fn build_agent_job( .is_some_and(|tools| tools.azure_devops.is_some()); if ado_proxy_enabled { steps.push(Step::Bash(prepare_ado_proxy_clients_step())); - steps.push(Step::Bash(start_ado_proxy_step(&ado_proxy_capabilities( - front_matter, - )))); + steps.push(Step::Bash(start_ado_proxy_step( + &common::ado_proxy_capabilities(front_matter), + ))); } // 15. MCP Gateway (MCPG), which launches SafeOutputs as a stdio child. @@ -3228,23 +3228,6 @@ fn safe_outputs_summary_step(reviewed: &[String]) -> BashStep { .with_condition(Condition::Always) } -/// Resolve the capabilities the policy engine should enable. -/// -/// Defaults to the full catalog. That is deliberately broad *within* a narrow -/// boundary: every catalogued operation is a `GET` or `OPTIONS`, and the -/// always-denied route families exclude ACLs, tokens, service endpoints, -/// variable groups and secure files. So the default grants read access to -/// project metadata the agent could already reach, while removing the -/// credential that previously made writes and secret reads possible at all. -/// -/// Starting narrower would leave the Azure DevOps MCP unable to answer most -/// questions, which pushes authors back towards handing agents raw -/// credentials — the outcome this design exists to prevent. `permissions.read` -/// narrows this set once its object form is accepted. -fn ado_proxy_capabilities(_front_matter: &FrontMatter) -> Vec { - Capability::ALL.to_vec() -} - /// Prepare the host-side prerequisites for routing the Azure DevOps MCP /// through the policy engine. /// @@ -4376,7 +4359,10 @@ mod tests { "---\nname: t\ndescription: x\ntools:\n azure-devops:\n org: 'myorg'\n---\n", ) .unwrap(); - assert_eq!(ado_proxy_capabilities(&fm), Capability::ALL.to_vec()); + assert_eq!( + common::ado_proxy_capabilities(&fm), + Capability::ALL.to_vec() + ); } // ── run_agent_step topology attachment ────────────────────────────────── @@ -4969,4 +4955,4 @@ mod tests { "ubuntu-22.04" ); } -} +} \ No newline at end of file diff --git a/src/compile/az_wrapper.rs b/src/compile/az_wrapper.rs index 05a21c54b..43e63241d 100644 --- a/src/compile/az_wrapper.rs +++ b/src/compile/az_wrapper.rs @@ -32,17 +32,30 @@ //! *availability* control: enforcement comes from routing, so a client that //! declines the certificate fails closed rather than escaping the policy. -use super::common::{AZ_ALLOWED_GROUPS, AZ_WRAPPER_CA_PATH, AZ_WRAPPER_DIR}; +use super::common::{AZ_WRAPPER_CA_PATH, AZ_WRAPPER_DIR, az_allowed_groups}; +use crate::ado_proxy::catalog::Capability; /// Render the wrapper script. /// /// `engine_host` is the policy engine's container name, which AWF registers in /// the agent's `/etc/hosts` when it attaches the container to the internal -/// network. +/// network. `capabilities` are the ones the policy actually grants, so the +/// wrapper refuses a command group the engine would refuse anyway — with an +/// explanation, rather than an opaque `403` several layers down. #[allow(dead_code)] -pub fn render_az_wrapper(engine_host: &str, connect_port: u16, sentinel: &str) -> String { - let allowed_list = AZ_ALLOWED_GROUPS.join(" "); - let allowed_display = AZ_ALLOWED_GROUPS.join(", "); +pub fn render_az_wrapper( + engine_host: &str, + connect_port: u16, + sentinel: &str, + capabilities: &[Capability], +) -> String { + let groups = az_allowed_groups(capabilities); + let allowed_list = groups.join(" "); + let allowed_display = if groups.is_empty() { + "none".to_string() + } else { + groups.join(", ") + }; format!( r##"#!/bin/sh @@ -134,7 +147,12 @@ mod tests { use crate::compile::common::ADO_MCP_TOKEN_SENTINEL; fn wrapper() -> String { - render_az_wrapper("awmg-ado-proxy", 11080, ADO_MCP_TOKEN_SENTINEL) + render_az_wrapper( + "awmg-ado-proxy", + 11080, + ADO_MCP_TOKEN_SENTINEL, + Capability::ALL, + ) } #[test] @@ -194,11 +212,13 @@ mod tests { #[test] fn refuses_command_groups_outside_the_policed_surface() { let script = wrapper(); - for group in AZ_ALLOWED_GROUPS { - assert!( - script.contains(group), - "{group} is catalogued and must be permitted" - ); + for capability in Capability::ALL { + if let Some(group) = capability.az_command_group() { + assert!( + script.contains(group), + "{group} is catalogued and must be permitted" + ); + } } assert!(script.contains("is not available to this agent")); // An actionable message: a bare denial invites the agent to retry the @@ -206,6 +226,45 @@ mod tests { assert!(script.contains("safe-outputs.md")); } + #[test] + fn advertises_only_what_the_policy_actually_grants() { + // `az artifacts` was briefly permitted while no catalogued operation + // backed it, so the call passed the wrapper and was refused by the + // engine. The allow-list is now derived from the granted capabilities. + assert!(!wrapper().contains("artifacts")); + + // Narrowing the policy narrows the wrapper with it. + let repos_only = render_az_wrapper( + "awmg-ado-proxy", + 11080, + ADO_MCP_TOKEN_SENTINEL, + &[Capability::Discovery, Capability::Repos], + ); + assert!(repos_only.contains(" repos ")); + for absent in ["devops", "boards", "pipelines"] { + assert!( + !repos_only.contains(&format!(" {absent} ")), + "{absent} is not granted and must not be advertised: {repos_only}" + ); + } + } + + #[test] + fn rest_stays_available_whatever_the_capabilities() { + // `az rest` is contained by the catalog, not by this list: measured + // against a live engine it completed a catalogued read and was refused + // 403 for a denied route family and for a POST. Excluding it would + // also contradict `az devops invoke`, which reaches the same surface. + assert!(wrapper().contains(" rest ")); + let narrow = render_az_wrapper( + "awmg-ado-proxy", + 11080, + ADO_MCP_TOKEN_SENTINEL, + &[Capability::Discovery], + ); + assert!(narrow.contains(" rest ")); + } + #[test] fn execs_the_real_binary_without_re_entering_itself() { let script = wrapper(); diff --git a/src/compile/common.rs b/src/compile/common.rs index 11bbc32fb..3726af200 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -11,6 +11,7 @@ use super::types::{ CheckoutFetchOpts, CompileTarget, FrontMatter, PipelineParameter, PoolConfig, ReposItem, Repository, SELF_CHECKOUT_ALIAS, }; +use crate::ado_proxy::catalog::Capability; use crate::allowed_hosts::{CORE_ALLOWED_HOSTS, mcp_required_hosts}; use crate::compile::types::McpConfig; use crate::ecosystem_domains::{ @@ -1677,12 +1678,45 @@ pub const AZ_WRAPPER_CA_PATH: &str = "/tmp/ado-aw-lib/ado-proxy-ca.pem"; /// Azure CLI command groups the wrapper permits. /// -/// These are the groups whose traffic the catalog actually describes. Anything -/// else — `az vm`, `az storage`, `az ad` — would leave the policed surface, so -/// the wrapper refuses it with an explanation rather than letting it fail -/// somewhere less legible. -#[allow(dead_code)] -pub const AZ_ALLOWED_GROUPS: &[&str] = &["devops", "repos", "pipelines", "boards", "artifacts"]; +/// Derived from the capabilities the policy actually grants, so the wrapper +/// cannot advertise a command group the engine would refuse. Hand-maintaining +/// this list let `az artifacts` through the wrapper while no catalogued +/// operation backed it. +/// +/// `rest` is always present and is deliberately not capability-derived. It is +/// a general REST escape hatch, and the catalog — not this list — is what +/// contains it: measured against a live engine, `az rest` completed a +/// catalogued read, and was refused `403` for both a denied route family and a +/// `POST`. Excluding it would also be incoherent, since `az devops invoke` +/// expresses the same arbitrary Azure DevOps REST from inside an allowed +/// group. It reaches non-Azure-DevOps hosts exactly as before — tunnelled to +/// Squid, with no credential attached. +pub fn az_allowed_groups(capabilities: &[Capability]) -> Vec<&'static str> { + let mut groups: Vec<&'static str> = Capability::ALL + .iter() + .filter(|capability| capabilities.contains(capability)) + .filter_map(|capability| capability.az_command_group()) + .collect(); + groups.push("rest"); + groups +} + +/// Resolve the capabilities the policy engine should enable. +/// +/// Defaults to the full catalog. That is deliberately broad *within* a narrow +/// boundary: every catalogued operation is a `GET` or `OPTIONS`, and the +/// always-denied route families exclude ACLs, tokens, service endpoints, +/// variable groups and secure files. So the default grants read access to +/// project metadata the agent could already reach, while removing the +/// credential that previously made writes and secret reads possible at all. +/// +/// Starting narrower would leave the Azure DevOps MCP unable to answer most +/// questions, which pushes authors back towards handing agents raw +/// credentials — the outcome this design exists to prevent. `permissions.read` +/// narrows this set once its object form is accepted. +pub fn ado_proxy_capabilities(_front_matter: &FrontMatter) -> Vec { + Capability::ALL.to_vec() +} /// Runner-side path of the CA certificate the policy engine publishes. /// diff --git a/src/compile/extensions/azure_cli.rs b/src/compile/extensions/azure_cli.rs index 3958cb5fc..4be7d1678 100644 --- a/src/compile/extensions/azure_cli.rs +++ b/src/compile/extensions/azure_cli.rs @@ -1,4 +1,5 @@ use super::{CompileContext, CompilerExtension, Declarations, ExtensionPhase}; +use crate::ado_proxy::catalog::Capability; use crate::compile::common::{ ADO_MCP_TOKEN_SENTINEL, ADO_PROXY_CONTAINER_NAME, ADO_PROXY_LISTEN_PORT, AZ_WRAPPER_DIR, AZ_WRAPPER_PATH, @@ -81,14 +82,15 @@ impl CompilerExtension for AzureCliExtension { /// `condition: ne(variables['AW_AZ_MOUNTS'], '')`. fn declarations(&self, ctx: &CompileContext) -> anyhow::Result { let proxied = crate::compile::common::ado_proxy_enabled(ctx.front_matter); + let capabilities = crate::compile::common::ado_proxy_capabilities(ctx.front_matter); let mut agent_prepare_steps = vec![Step::Bash(detection_bash_step())]; if proxied { // Installed before the prompt is appended so the advisory and the // wrapper cannot describe different worlds. - agent_prepare_steps.push(Step::Bash(install_az_wrapper_step())); + agent_prepare_steps.push(Step::Bash(install_az_wrapper_step(&capabilities))); } - agent_prepare_steps.push(Step::Bash(prompt_append_bash_step())); + agent_prepare_steps.push(Step::Bash(prompt_append_bash_step(proxied, &capabilities))); Ok(Declarations { network_hosts: vec![ @@ -130,11 +132,12 @@ impl CompilerExtension for AzureCliExtension { /// `az` on the runner there is nothing for the wrapper to exec, and shadowing a /// missing binary would turn a clear "command not found" into a confusing /// wrapper error. -fn install_az_wrapper_step() -> BashStep { +fn install_az_wrapper_step(capabilities: &[Capability]) -> BashStep { let wrapper = crate::compile::az_wrapper::render_az_wrapper( ADO_PROXY_CONTAINER_NAME, ADO_PROXY_LISTEN_PORT, ADO_MCP_TOKEN_SENTINEL, + capabilities, ); // Indent the body for the heredoc without altering its content. let script = format!( @@ -167,9 +170,37 @@ fn detection_bash_step() -> BashStep { } /// Append an Azure CLI advisory when the detection step found `az`. -fn prompt_append_bash_step() -> BashStep { - let script = "cat >> \"/tmp/awf-tools/agent-prompt.md\" << 'AZURE_CLI_PROMPT_EOF'\n\ +/// +/// Two quite different messages, because the agent's actual capability differs. +/// Getting this wrong is not cosmetic: an agent told a command is unavailable +/// will not try it, and one told it has access it lacks will retry a failing +/// call or invent a workaround. The unproxied text deliberately claims nothing +/// beyond "not pre-authenticated" — an earlier revision overclaimed here. +fn prompt_append_bash_step(proxied: bool, capabilities: &[Capability]) -> BashStep { + let body = if proxied { + let groups = crate::compile::common::az_allowed_groups(capabilities); + let group_list = groups + .iter() + .map(|g| format!("`az {g}`")) + .collect::>() + .join(", "); + format!( + "\n\ +---\n\ +\n\ +## Azure CLI (`az`)\n\ +\n\ +The Azure CLI is available and **pre-configured for Azure DevOps reads**. You do not need to sign in, and no credential is present in this sandbox for you to use or leak.\n\ \n\ +- **Available** — {group_list}, scoped to the current organization and project. These are **read-only**: listing and getting work, and the results are real. `az rest` and `az devops invoke` also work for Azure DevOps reads, so a catalogued endpoint without a dedicated command is still reachable.\n\ +- **Not available** — creating, updating or deleting anything; reading secrets (service connections, variable groups, secure files, tokens, permissions); any other organization or project; and every other `az` command group, including Azure Resource Manager (`az resource`, `az account`, `az group`) and Microsoft Graph (`az ad`).\n\ +\n\ +Requests outside that boundary are refused by a policy proxy, not by a misconfiguration — retrying, changing the URL, or trying to authenticate will not help. To *change* anything, emit a safe output instead; that is the supported path for writes.\n\ +\n\ +If a read you need is refused, file a `missing-tool` safe output naming `azure-cli` and the exact command, so the operator can extend the catalog rather than leaving you blocked.\n" + ) + } else { + "\n\ ---\n\ \n\ ## Azure CLI (`az`)\n\ @@ -180,10 +211,17 @@ The Azure CLI is available inside this sandbox at `/usr/bin/az`, but ado-aw does - **Azure Resource Manager and Microsoft Graph** \u{2014} `az resource`, `az account`, `az group`, `az ad`, and authenticated `az rest` calls are not configured for agent use.\n\ - Do not sign in or place Azure credentials in the sandbox. Request a supported tool instead.\n\ \n\ -If a command you need isn't covered above, file a `missing-tool` safe output naming `azure-cli` so the operator can extend coverage rather than blocking on it silently.\n\ +If a command you need isn't covered above, file a `missing-tool` safe output naming `azure-cli` so the operator can extend coverage rather than blocking on it silently.\n" + .to_string() + }; + + let script = format!( + "cat >> \"/tmp/awf-tools/agent-prompt.md\" << 'AZURE_CLI_PROMPT_EOF'\n\ +{body}\ AZURE_CLI_PROMPT_EOF\n\ \n\ -echo \"Azure CLI prompt appended\"\n"; +echo \"Azure CLI prompt appended\"\n" + ); BashStep::new("Append Azure CLI prompt", script).with_condition(Condition::Ne( Expr::Variable("AW_AZ_MOUNTS".to_string()), Expr::Literal(String::new()), From 2634dfac07dc937a00301bd2cb018fe1954746ae Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 4 Aug 2026 21:03:14 +0100 Subject: [PATCH 20/42] fix(ado-proxy): populate the policy from config, restoring repository reads PolicyDocument::new took a capability slice and never saw the front matter, so every field it could not derive was invented: project_id, repository and repository_id were hard-coded None, and skip_serializing_if dropped them from the JSON entirely. The bundle's sameIdentifier() returns false for undefined, so isCurrentRepository was ALWAYS false and all twelve catalogued current-repository-path operations denied unconditionally - the repos capability was dead. isCurrentProject fell back to the name alone, so the current project addressed by GUID was also denied, which matters because az substitutes whichever form it cached. None of this surfaced because absent reads as "match nothing": every gap was a silent denial rather than an error, and the live tests so far exercised discovery, the project-validation probe and always-denied families - none of which take the repository path. The constructor now takes FrontMatter, so adding a policy field forces a decision about which configuration populates it. Capability resolution moves next to it, and common.rs re-exports it, so the emitted policy and the az wrapper's allow-list cannot disagree. Also folds in the scope-identifier work: - project_id, repository and repository_id are substituted at step time from System.TeamProjectId, Build.Repository.Name and Build.Repository.ID, so a compiled pipeline stays portable; - System.TeamProjectId is added to ALLOWED_ADO_MACROS with its rationale; - the step now fails if any placeholder survives substitution, since a literal placeholder would be read as an organization name matching nothing - a total denial that reads as a policy decision. The two organization derivations are collapsed into one shared helper, and both were wrong for a form the other handled. engine.rs stripped a literal https://dev.azure.com/ prefix, a no-op for https://myorg.visualstudio.com/ that yields the whole URL; the proxy step took the last path segment, which returns myorg.visualstudio.com for that same URL. Measured against both shapes: the helper now returns "contoso" for https://dev.azure.com/contoso/ and for https://contoso.visualstudio.com/. Verified by extracting the emitted step from a compiled lock file and running it on both collection forms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- src/ado_proxy/policy.rs | 184 +++++++++++++++++++++++++++----- src/compile/agentic_pipeline.rs | 126 +++++++++++++++++----- src/compile/common.rs | 93 +++++++++++++--- src/compile/ir/env.rs | 5 + src/compile/mod.rs | 1 + src/engine.rs | 22 ++-- 6 files changed, 347 insertions(+), 84 deletions(-) diff --git a/src/ado_proxy/policy.rs b/src/ado_proxy/policy.rs index 1a7388e86..5976df7ae 100644 --- a/src/ado_proxy/policy.rs +++ b/src/ado_proxy/policy.rs @@ -19,6 +19,48 @@ use serde::Serialize; use super::catalog::{ CATALOG_SCHEMA_VERSION, Capability, ORGANIZATION_HOST, SPS_FALLBACK_HOST, }; +use crate::compile::types::FrontMatter; + +/// Resolve the capabilities the policy engine should enable. +/// +/// An author who names `capabilities:` gets exactly those, plus the always-on +/// ones. Omitting the key selects the whole catalog — deliberately broad +/// *within* a narrow boundary: every catalogued operation is a `GET` or +/// `OPTIONS`, and the always-denied route families exclude ACLs, tokens, +/// service endpoints, variable groups and secure files. Starting narrower +/// would leave the Azure DevOps MCP unable to answer most questions, which +/// pushes authors back towards handing agents raw credentials — the outcome +/// this design exists to prevent. +/// +/// The result is always in [`Capability::ALL`] order, so reordering a +/// `capabilities:` list cannot change the compiled pipeline. +pub fn ado_proxy_capabilities(front_matter: &FrontMatter) -> Vec { + let requested: Option> = front_matter + .permissions + .as_ref() + .and_then(|permissions| permissions.read.as_ref()) + .and_then(crate::compile::types::ReadPermissionConfig::options) + .filter(|options| !options.capabilities.is_empty()) + .map(|options| { + options + .capabilities + .iter() + .map(|capability| capability.to_catalog()) + .collect() + }); + + Capability::ALL + .iter() + .copied() + .filter(|capability| match &requested { + // `discovery` is always on: every client resolves resource areas + // before its first real call, so a policy without it produces a + // proxy no supported client can actually use. + Some(selected) => capability.is_always_on() || selected.contains(capability), + None => true, + }) + .collect() +} /// Placeholder substituted with the organization name at step time. pub const ORGANIZATION_PLACEHOLDER: &str = "${ADO_PROXY_ORGANIZATION}"; @@ -26,6 +68,19 @@ pub const ORGANIZATION_PLACEHOLDER: &str = "${ADO_PROXY_ORGANIZATION}"; /// Placeholder substituted with the project name at step time. pub const PROJECT_PLACEHOLDER: &str = "${ADO_PROXY_PROJECT}"; +/// Placeholder substituted with the project GUID at step time. +/// +/// Clients address the current project by name in some calls and by GUID in +/// others — `az` substitutes whichever it cached — so both forms must be +/// present or a GUID-addressed request is denied. +pub const PROJECT_ID_PLACEHOLDER: &str = "${ADO_PROXY_PROJECT_ID}"; + +/// Placeholder substituted with the current repository name at step time. +pub const REPOSITORY_PLACEHOLDER: &str = "${ADO_PROXY_REPOSITORY}"; + +/// Placeholder substituted with the current repository GUID at step time. +pub const REPOSITORY_ID_PLACEHOLDER: &str = "${ADO_PROXY_REPOSITORY_ID}"; + /// The policy document handed to the `ado-proxy` bundle via `--policy-file`. /// /// Field names and shape are a contract with `parsePolicy` in @@ -48,19 +103,29 @@ pub struct PolicyDocument { } impl PolicyDocument { - /// Build the document for a set of author-requested capabilities. + /// Build the document from the compiler's own configuration. + /// + /// Taking [`FrontMatter`] rather than a capability slice is deliberate. A + /// constructor narrower than the configuration cannot express it, so every + /// input it cannot see becomes a silent default — and because the bundle + /// treats an absent field as "match nothing", each of those defaults is an + /// invisible *denial* rather than a loud error. That is how the current + /// repository came to be unreachable: `repository` was hard-coded `None`, + /// omitted from the JSON, and twelve catalogued operations denied + /// unconditionally without a single test noticing. + /// + /// Adding a field to this struct should therefore force a decision about + /// which piece of configuration populates it. /// /// Always-on capabilities are added regardless of what the author asked /// for, and the result is emitted in [`Capability::ALL`] order so the /// document is stable no matter how the front matter was written — an /// author reordering their `capabilities:` list must not produce a /// different pipeline. - pub fn new(requested: &[Capability]) -> Self { - let capabilities = Capability::ALL + pub fn new(front_matter: &FrontMatter) -> Self { + let requested = ado_proxy_capabilities(front_matter); + let capabilities = requested .iter() - .filter(|capability| { - capability.is_always_on() || requested.contains(capability) - }) .map(|capability| capability.as_str()) .collect(); @@ -68,9 +133,13 @@ impl PolicyDocument { catalog_version: CATALOG_SCHEMA_VERSION, organization: ORGANIZATION_PLACEHOLDER.to_string(), project: PROJECT_PLACEHOLDER.to_string(), - project_id: None, - repository: None, - repository_id: None, + // Substituted at step time like the organization and project. A + // compiled pipeline is routinely queued against a different + // project than the one it was compiled in, so baking these in + // would make a lock file wrong the moment it moved. + project_id: Some(PROJECT_ID_PLACEHOLDER.to_string()), + repository: Some(REPOSITORY_PLACEHOLDER.to_string()), + repository_id: Some(REPOSITORY_ID_PLACEHOLDER.to_string()), capabilities, // Every catalogued host must appear: one the bundle policed but // the document omitted would be byte-tunnelled to Squid instead, @@ -90,9 +159,26 @@ impl PolicyDocument { mod tests { use super::*; + /// Front matter with no explicit read policy — the common case. + fn plain() -> FrontMatter { + crate::compile::parse_markdown("---\nname: t\ndescription: x\n---\n") + .unwrap() + .0 + } + + /// Front matter naming an explicit capability set. + fn with_capabilities(list: &str) -> FrontMatter { + crate::compile::parse_markdown(&format!( + "---\nname: t\ndescription: x\npermissions:\n read:\n \ + service-connection: my-read-sc\n capabilities: [{list}]\n---\n" + )) + .unwrap() + .0 + } + #[test] fn discovery_is_present_even_when_unrequested() { - let document = PolicyDocument::new(&[Capability::Repos]); + let document = PolicyDocument::new(&with_capabilities("repos")); assert!( document.capabilities.contains(&"discovery"), "discovery is always on; without it no supported client can \ @@ -103,8 +189,8 @@ mod tests { #[test] fn capability_order_is_independent_of_request_order() { - let one = PolicyDocument::new(&[Capability::Boards, Capability::Core]); - let two = PolicyDocument::new(&[Capability::Core, Capability::Boards]); + let one = PolicyDocument::new(&with_capabilities("boards, core")); + let two = PolicyDocument::new(&with_capabilities("core, boards")); assert_eq!( one.capabilities, two.capabilities, "author-visible ordering must not change the compiled pipeline" @@ -113,14 +199,31 @@ mod tests { #[test] fn unrequested_capabilities_are_absent() { - let document = PolicyDocument::new(&[]); + let document = PolicyDocument::new(&with_capabilities("repos")); for capability in Capability::ALL { - if capability.is_always_on() { + if capability.is_always_on() || *capability == Capability::Repos { continue; } assert!( !document.capabilities.contains(&capability.as_str()), - "{} was never requested and must not be granted", + "{} was never requested and must not be granted: {:?}", + capability.as_str(), + document.capabilities + ); + } + } + + #[test] + fn omitting_capabilities_selects_the_whole_catalog() { + // Deliberately broad within a narrow boundary: every catalogued + // operation is a GET or OPTIONS, and secret-bearing route families are + // denied outright. Starting narrower would leave the MCP unable to + // answer most questions. + let document = PolicyDocument::new(&plain()); + for capability in Capability::ALL { + assert!( + document.capabilities.contains(&capability.as_str()), + "{} must be granted when the author names none", capability.as_str() ); } @@ -128,7 +231,7 @@ mod tests { #[test] fn every_catalogued_protected_host_is_declared() { - let document = PolicyDocument::new(&[]); + let document = PolicyDocument::new(&plain()); for host in super::super::catalog::catalog().protected_hosts { assert!( document.protected_hosts.contains(host), @@ -140,7 +243,7 @@ mod tests { #[test] fn catalog_version_matches_the_catalog() { - let document = PolicyDocument::new(&[]); + let document = PolicyDocument::new(&plain()); assert_eq!( document.catalog_version, catalog_version_from_catalog(), @@ -153,20 +256,43 @@ mod tests { } #[test] - fn scope_is_left_as_placeholders_for_step_time_substitution() { - let document = PolicyDocument::new(&[]); - assert_eq!(document.organization, ORGANIZATION_PLACEHOLDER); - assert_eq!(document.project, PROJECT_PLACEHOLDER); + fn every_current_scope_identifier_is_emitted() { + // The regression that motivated taking the config: `repository` was + // hard-coded `None` and omitted from the JSON, so the bundle — which + // treats absent as "match nothing" — denied all twelve catalogued + // repository operations without a single test noticing. + let json = PolicyDocument::new(&plain()).to_json(); + for field in [ + "organization", + "project", + "project_id", + "repository", + "repository_id", + ] { + assert!( + json.contains(&format!("\"{field}\"")), + "{field} is absent, which the bundle reads as match-nothing: {json}" + ); + } + assert!( + !json.contains("null"), + "a present-but-null field is not the same as an absent one: {json}" + ); } #[test] - fn json_omits_unset_optional_scope_fields() { - // The bundle rejects unknown keys, and treats a present-but-null - // narrowing field differently from an absent one. Emitting `null` - // would be a startup failure. - let json = PolicyDocument::new(&[]).to_json(); - assert!(!json.contains("null"), "unset scope fields must be omitted: {json}"); - assert!(!json.contains("project_id")); - assert!(!json.contains("repository")); + fn scope_is_left_as_placeholders_for_step_time_substitution() { + // A compiled pipeline is routinely queued against a different project + // than it was compiled in, so baking any of these in would make a lock + // file wrong the moment it moved. + let document = PolicyDocument::new(&plain()); + assert_eq!(document.organization, ORGANIZATION_PLACEHOLDER); + assert_eq!(document.project, PROJECT_PLACEHOLDER); + assert_eq!(document.project_id.as_deref(), Some(PROJECT_ID_PLACEHOLDER)); + assert_eq!(document.repository.as_deref(), Some(REPOSITORY_PLACEHOLDER)); + assert_eq!( + document.repository_id.as_deref(), + Some(REPOSITORY_ID_PLACEHOLDER) + ); } } diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index aef5a2551..cecd5c3b2 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -71,7 +71,7 @@ use super::common::{ HEADER_MARKER, MCPG_CONTAINER_NAME, MCPG_DOMAIN, MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, image_ref, }; use super::extensions::ado_script as paths; -use crate::ado_proxy::catalog::{self, Capability}; +use crate::ado_proxy::catalog; use crate::ado_proxy::policy::PolicyDocument; use super::extensions::{CompileContext, CompilerExtension, Declarations, Extension, McpgConfig}; use super::ir::condition::{Condition, Expr}; @@ -1001,9 +1001,7 @@ fn build_agent_job( .is_some_and(|tools| tools.azure_devops.is_some()); if ado_proxy_enabled { steps.push(Step::Bash(prepare_ado_proxy_clients_step())); - steps.push(Step::Bash(start_ado_proxy_step( - &common::ado_proxy_capabilities(front_matter), - ))); + steps.push(Step::Bash(start_ado_proxy_step(front_matter))); } // 15. MCP Gateway (MCPG), which launches SafeOutputs as a stdio child. @@ -3334,8 +3332,8 @@ fn stop_mcpg_step() -> BashStep { /// design exists to withhold. /// /// Not yet emitted: see [`stop_ado_proxy_step`]. -fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { - let policy = PolicyDocument::new(capabilities).to_json(); +fn start_ado_proxy_step(front_matter: &FrontMatter) -> BashStep { + let policy = PolicyDocument::new(front_matter).to_json(); let hosts = catalog::catalog().protected_hosts; // Mint one leaf per catalogued protected host. A host without a leaf // cannot be intercepted, so this list must track the catalog rather than @@ -3363,9 +3361,16 @@ fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { # Policy document. Non-secret, so it is mounted rather than streamed.\n\ # Scope is substituted here rather than at compile time so the same\n\ # compiled pipeline can be queued against a different project.\n\ - ADO_PROXY_COLLECTION=\"$(System.CollectionUri)\"\n\ - ADO_PROXY_ORGANIZATION=$(printf '%s' \"$ADO_PROXY_COLLECTION\" | sed -e 's#/*$##' -e 's#.*/##')\n\ + #\n\ + # Both the name and the GUID of the project and repository are\n\ + # supplied: clients address them either way — `az` substitutes\n\ + # whichever it cached — and the bundle treats an absent identifier as\n\ + # matching nothing, so omitting one is a silent denial.\n\ +{org_resolve}\ ADO_PROXY_PROJECT=\"$(System.TeamProject)\"\n\ + ADO_PROXY_PROJECT_ID=\"$(System.TeamProjectId)\"\n\ + ADO_PROXY_REPOSITORY=\"$(Build.Repository.Name)\"\n\ + ADO_PROXY_REPOSITORY_ID=\"$(Build.Repository.ID)\"\n\ mkdir -p \"$PROXY_DIR/policy\"\n\ cat > \"$PROXY_DIR/policy/policy.json\" <<'ADO_PROXY_POLICY_EOF'\n\ {policy}\n\ @@ -3373,7 +3378,18 @@ fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { sed -i \\\n \ -e \"s|\\${{ADO_PROXY_ORGANIZATION}}|$ADO_PROXY_ORGANIZATION|g\" \\\n \ -e \"s|\\${{ADO_PROXY_PROJECT}}|$ADO_PROXY_PROJECT|g\" \\\n \ + -e \"s|\\${{ADO_PROXY_PROJECT_ID}}|$ADO_PROXY_PROJECT_ID|g\" \\\n \ + -e \"s|\\${{ADO_PROXY_REPOSITORY}}|$ADO_PROXY_REPOSITORY|g\" \\\n \ + -e \"s|\\${{ADO_PROXY_REPOSITORY_ID}}|$ADO_PROXY_REPOSITORY_ID|g\" \\\n \ \"$PROXY_DIR/policy/policy.json\"\n\ + \n\ + # A surviving placeholder would be read as a literal organization or\n\ + # repository name, matching nothing — a total denial that reads as a\n\ + # policy decision rather than a bug.\n\ + if grep -q 'ADO_PROXY_' \"$PROXY_DIR/policy/policy.json\"; then\n \ + echo \"##vso[task.complete result=Failed]ado-proxy policy still contains an unsubstituted placeholder\"\n \ + exit 1\n\ + fi\n\ echo \"ado-proxy policy:\"\n\ python3 -m json.tool < \"$PROXY_DIR/policy/policy.json\"\n\ \n\ @@ -3462,6 +3478,7 @@ fn start_ado_proxy_step(capabilities: &[Capability]) -> BashStep { fi\n\ echo \"ado-proxy is ready at $ADO_PROXY_IP\"\n\ echo \"##vso[task.setvariable variable=ADO_PROXY_IP]$ADO_PROXY_IP\"\n", + org_resolve = common::resolve_ado_organization_bash(" "), ado_proxy_path = paths::ADO_PROXY_PATH, ca_host_path = ADO_PROXY_PUBLIC_CA_HOST_PATH, az_wrapper_dir = AZ_WRAPPER_DIR, @@ -4328,7 +4345,7 @@ mod tests { // Real `az` hit exactly that: Python's requests verified the chain // strictly and refused, while every Node client had been happy. The // key usage must therefore be declared explicitly. - let script = start_ado_proxy_step(&[]).script; + let script = start_ado_proxy_step(&proxy_fm()).script; assert!( script.contains("keyUsage=critical,keyCertSign,cRLSign"), "the CA must declare keyCertSign or strict verifiers reject it: {script}" @@ -4350,18 +4367,53 @@ mod tests { } #[test] - fn the_default_capability_set_is_the_whole_catalog() { - // Deliberately broad within a narrow boundary: every catalogued - // operation is a GET or OPTIONS, and secret-bearing route families are - // denied outright. Starting narrower would leave the MCP unable to - // answer most questions, pushing authors back to raw credentials. - let (fm, _) = crate::compile::parse_markdown( - "---\nname: t\ndescription: x\ntools:\n azure-devops:\n org: 'myorg'\n---\n", - ) - .unwrap(); + fn ado_proxy_supplies_every_current_scope_identifier() { + // The bundle treats an absent identifier as matching nothing, so a + // missing one is a silent denial rather than an error. `repository` + // was previously omitted entirely, which killed all twelve catalogued + // repository operations without any test noticing. + let script = start_ado_proxy_step(&proxy_fm()).script; + for (variable, macro_name) in [ + ("ADO_PROXY_PROJECT", "System.TeamProject"), + ("ADO_PROXY_PROJECT_ID", "System.TeamProjectId"), + ("ADO_PROXY_REPOSITORY", "Build.Repository.Name"), + ("ADO_PROXY_REPOSITORY_ID", "Build.Repository.ID"), + ] { + assert!( + script.contains(&format!("{variable}=\"$({macro_name})\"")), + "{variable} must be sourced from $({macro_name}): {script}" + ); + assert!( + script.contains(&format!("s|\\${{{variable}}}|${variable}|g")), + "{variable} must be substituted into the policy: {script}" + ); + } + } + + #[test] + fn ado_proxy_refuses_to_start_on_an_unsubstituted_placeholder() { + // A surviving `${ADO_PROXY_*}` would be read as a literal + // organization or repository name, matching nothing — a total denial + // that reads as a policy decision rather than a bug. + let script = start_ado_proxy_step(&proxy_fm()).script; + assert!(script.contains("grep -q 'ADO_PROXY_' \"$PROXY_DIR/policy/policy.json\"")); + assert!(script.contains("unsubstituted placeholder")); + } + + #[test] + fn ado_proxy_derives_the_organization_from_the_collection_uri() { + // A fixed-prefix strip is a no-op for a *.visualstudio.com collection + // URL, and a bare last-path-segment rule returns the whole host for + // it. Both shapes are handled by the shared helper. + let script = start_ado_proxy_step(&proxy_fm()).script; + assert!( + script.contains("if (NF>1) print $NF"), + "organization derivation must handle both collection forms: {script}" + ); assert_eq!( - common::ado_proxy_capabilities(&fm), - Capability::ALL.to_vec() + script.matches("ADO_PROXY_ORGANIZATION=$(").count(), + 1, + "exactly one derivation, shared with engine.rs" ); } @@ -4441,6 +4493,16 @@ mod tests { } + /// Front matter for the proxy step tests: the ADO tool enabled with a read + /// service connection, which is the configuration that turns the engine on. + fn proxy_fm() -> FrontMatter { + crate::compile::parse_markdown( + "---\nname: t\ndescription: x\ntools:\n azure-devops:\n org: myorg\npermissions:\n read: my-read-sc\n---\n", + ) + .unwrap() + .0 + } + // ── start_ado_proxy_step / stop_ado_proxy_step ────────────────────────── #[test] @@ -4448,7 +4510,7 @@ mod tests { // AWF chroots the agent with /tmp mounted at both /tmp and /host/tmp, // so anything the step writes under /tmp is agent-readable. The // credential this design exists to withhold must not land there. - let script = start_ado_proxy_step(&[]).script; + let script = start_ado_proxy_step(&proxy_fm()).script; assert!( script.contains("mktemp -d \"$(Agent.TempDirectory)/ado-proxy."), @@ -4466,7 +4528,7 @@ mod tests { #[test] fn ado_proxy_streams_material_on_stdin_rather_than_via_env_or_argv() { - let step = start_ado_proxy_step(&[]); + let step = start_ado_proxy_step(&proxy_fm()); assert!( step.script.contains("printf '%s' \"$PROXY_MATERIAL\" | docker run -i"), @@ -4488,7 +4550,7 @@ mod tests { #[test] fn ado_proxy_destroys_the_signing_key_after_handover() { - let script = start_ado_proxy_step(&[]).script; + let script = start_ado_proxy_step(&proxy_fm()).script; assert!( script.contains("shred -u \"$PROXY_DIR/ca.key\""), "the CA signing key must not outlive handover: {script}" @@ -4501,7 +4563,7 @@ mod tests { #[test] fn ado_proxy_publishes_only_the_ca_certificate() { - let script = start_ado_proxy_step(&[]).script; + let script = start_ado_proxy_step(&proxy_fm()).script; // `--public-ca-file` is an *output*: the proxy writes its interception // CA there so clients can trust it. It must land somewhere the agent // can read (AWF mounts /tmp into the chroot) — unlike the signing key. @@ -4521,7 +4583,7 @@ mod tests { #[test] fn ado_proxy_mints_a_leaf_for_every_catalogued_protected_host() { - let script = start_ado_proxy_step(&[]).script; + let script = start_ado_proxy_step(&proxy_fm()).script; for host in catalog::catalog().protected_hosts { assert!( script.contains(&format!("\"{host}\"")), @@ -4533,7 +4595,7 @@ mod tests { #[test] fn ado_proxy_egresses_only_through_squid() { - let script = start_ado_proxy_step(&[]).script; + let script = start_ado_proxy_step(&proxy_fm()).script; assert!( script.contains(&format!("--upstream-proxy {AWF_SQUID_URL}")), "the only egress must be Squid, so an outage is a 502 not a direct socket" @@ -4552,7 +4614,7 @@ mod tests { fn ado_proxy_reuses_the_existing_node_image() { // The proxy ships as an ado-script bundle already downloaded onto the // runner, so it must not introduce an image to build, pin or mirror. - let script = start_ado_proxy_step(&[]).script; + let script = start_ado_proxy_step(&proxy_fm()).script; assert_eq!(ADO_PROXY_IMAGE, common::ADO_MCP_IMAGE); assert!(script.contains(&format!("{ADO_PROXY_IMAGE} \\"))); assert!(script.contains(&format!("{}:/app/ado-proxy.js:ro", paths::ADO_PROXY_PATH))); @@ -4560,7 +4622,13 @@ mod tests { #[test] fn ado_proxy_embeds_a_policy_the_bundle_will_accept() { - let script = start_ado_proxy_step(&[Capability::Repos]).script; + let narrowed = crate::compile::parse_markdown( + "---\nname: t\ndescription: x\ntools:\n azure-devops:\n org: myorg\n\ + permissions:\n read:\n service-connection: my-read-sc\n capabilities: [repos]\n---\n", + ) + .unwrap() + .0; + let script = start_ado_proxy_step(&narrowed).script; assert!(script.contains("\"catalog_version\"")); assert!( script.contains("\"discovery\""), @@ -4578,7 +4646,7 @@ mod tests { // --rm only fires on clean exit; an OOM or SIGKILL leaves the // container, and with it a live credential, behind. assert!( - start_ado_proxy_step(&[]) + start_ado_proxy_step(&proxy_fm()) .script .contains(&format!("docker rm -f {ADO_PROXY_CONTAINER_NAME}")) ); diff --git a/src/compile/common.rs b/src/compile/common.rs index 3726af200..1858bbd20 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1647,6 +1647,42 @@ pub const ADO_PROXY_NETWORK_NAME: &str = "ado-aw-proxy-net"; /// Path the public interception CA is mounted at inside client containers. pub const ADO_MCP_CA_MOUNT: &str = "/etc/ado-proxy/ca.pem"; +/// Bash that derives the Azure DevOps organization name from +/// `$(System.CollectionUri)` into `$ADO_PROXY_ORGANIZATION`. +/// +/// `indent` is the leading whitespace each emitted line needs, so the same +/// helper can be dropped into differently-indented bodies. +/// +/// One implementation on purpose, because the two it replaced were both wrong +/// for a form the other handled. `engine.rs` stripped a literal +/// `https://dev.azure.com/` prefix, a no-op for `https://myorg.visualstudio.com/` +/// that yields the whole URL; taking the last path segment gets `dev.azure.com` +/// URLs right but returns `myorg.visualstudio.com` for the legacy host form. +/// +/// Both collection shapes are still issued by Azure DevOps, so this handles +/// each explicitly: with a path segment after the host, the last segment is +/// the organization (or collection); with none, the first label of the host +/// is. Getting this wrong is not cosmetic — in a policy document a wrong +/// organization matches nothing, denying every request in a way that reads as +/// a deliberate policy decision. +pub fn resolve_ado_organization_bash(indent: &str) -> String { + format!( + "{indent}# $(System.CollectionUri) is expanded by ADO before bash runs. Two\n\ + {indent}# shapes are in use: \"https://dev.azure.com/myorg/\" (organization in\n\ + {indent}# the path) and the legacy \"https://myorg.visualstudio.com/\"\n\ + {indent}# (organization in the host). Handle both — a fixed-prefix strip or a\n\ + {indent}# bare last-segment rule is silently wrong for one of them.\n\ + {indent}ADO_PROXY_COLLECTION=\"$(System.CollectionUri)\"\n\ + {indent}ADO_PROXY_ORGANIZATION=$(printf '%s' \"$ADO_PROXY_COLLECTION\" \\\n\ + {indent} | sed -e 's#^https\\?://##' -e 's#/*$##' \\\n\ + {indent} | awk -F/ '{{ if (NF>1) print $NF; else {{ sub(/\\..*$/, \"\", $1); print $1 }} }}')\n\ + {indent}if [ -z \"$ADO_PROXY_ORGANIZATION\" ]; then\n\ + {indent} echo \"##vso[task.complete result=Failed]cannot determine the Azure DevOps organization from System.CollectionUri\"\n\ + {indent} exit 1\n\ + {indent}fi\n" + ) +} + /// Whether this workflow routes Azure DevOps access through the policy engine. /// /// Enabling `tools.azure-devops` is what pulls in the engine: the MCP is @@ -1703,20 +1739,10 @@ pub fn az_allowed_groups(capabilities: &[Capability]) -> Vec<&'static str> { /// Resolve the capabilities the policy engine should enable. /// -/// Defaults to the full catalog. That is deliberately broad *within* a narrow -/// boundary: every catalogued operation is a `GET` or `OPTIONS`, and the -/// always-denied route families exclude ACLs, tokens, service endpoints, -/// variable groups and secure files. So the default grants read access to -/// project metadata the agent could already reach, while removing the -/// credential that previously made writes and secret reads possible at all. -/// -/// Starting narrower would leave the Azure DevOps MCP unable to answer most -/// questions, which pushes authors back towards handing agents raw -/// credentials — the outcome this design exists to prevent. `permissions.read` -/// narrows this set once its object form is accepted. -pub fn ado_proxy_capabilities(_front_matter: &FrontMatter) -> Vec { - Capability::ALL.to_vec() -} +/// Re-exported from [`crate::ado_proxy::policy`], which owns the rule, so the +/// `az` wrapper's allow-list and the emitted policy document cannot disagree +/// about what the agent may read. +pub use crate::ado_proxy::policy::ado_proxy_capabilities; /// Runner-side path of the CA certificate the policy engine publishes. /// @@ -5617,8 +5643,47 @@ safe-outputs: assert!(result.unwrap_err().to_string().contains("ADO expression")); } + #[test] + fn resolve_ado_organization_handles_every_collection_form() { + // Regression guard for the two derivations this replaced, each of + // which was silently wrong for a form the other handled: a literal + // `https://dev.azure.com/` prefix strip is a no-op for + // `https://myorg.visualstudio.com/`, while a bare last-path-segment + // rule returns `myorg.visualstudio.com` for that same URL. Measured + // against both shapes plus an on-prem collection. + let script = resolve_ado_organization_bash(""); + assert!( + !script.contains("#https://dev.azure.com/"), + "must not strip a fixed prefix: {script}" + ); + assert!( + script.contains("if (NF>1) print $NF"), + "path form must yield the last segment: {script}" + ); + assert!( + script.contains("sub(/\\..*$/, \"\", $1)"), + "host form must yield the first host label: {script}" + ); + assert!( + script.contains("cannot determine the Azure DevOps organization"), + "an empty organization must fail loudly, not match nothing" + ); + } + + #[test] + fn resolve_ado_organization_indents_every_line() { + let script = resolve_ado_organization_bash(" "); + for line in script.lines().filter(|line| !line.is_empty()) { + assert!( + line.starts_with(" "), + "every line must carry the requested indent: {line:?}" + ); + } + } + #[test] fn test_validate_permissions_read_policy_allows_scalar_and_rejects_object() { + let (scalar, _) = parse_markdown( "---\nname: test\ndescription: test\npermissions:\n read: my-read-sc\n---\n", ) diff --git a/src/compile/ir/env.rs b/src/compile/ir/env.rs index be144c426..f8bbf8db9 100644 --- a/src/compile/ir/env.rs +++ b/src/compile/ir/env.rs @@ -171,6 +171,11 @@ pub const ALLOWED_ADO_MACROS: &[&str] = &[ "System.AccessToken", "System.CollectionUri", "System.TeamProject", + // Project GUID. Azure DevOps clients address a project by name in some + // calls and by GUID in others — `az` substitutes whichever it cached — so + // the ado-proxy policy document must carry both forms or a GUID-addressed + // request to the current project is denied. + "System.TeamProjectId", "System.DefinitionId", // PR-build identifiers — coalesced with synthPr.* outputs on the // synthetic-from-CI path. diff --git a/src/compile/mod.rs b/src/compile/mod.rs index c45951620..dd0310dc7 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -51,6 +51,7 @@ pub use common::AWF_VERSION; pub use common::HEADER_MARKER; pub use common::MCPG_VERSION; pub use common::normalize_source_path; +pub use common::resolve_ado_organization_bash; #[allow(unused_imports)] pub use common::parse_markdown; #[allow(unused_imports)] diff --git a/src/engine.rs b/src/engine.rs index 519cc0b7f..d1014afa0 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1100,23 +1100,21 @@ fn copilot_install_steps( // system variable $(System.CollectionUri) at runtime and // stores it as a pipeline variable. // - // $(System.CollectionUri) is expanded by ADO before bash runs - // (e.g. "https://dev.azure.com/myorg/"); the parameter - // expansions strip the prefix and trailing slash to yield just - // the org name ("myorg"). - let step = "\ + // Uses the shared derivation so this and the ado-proxy policy + // step cannot disagree about what the organization is. The + // previous local implementation stripped a literal + // `https://dev.azure.com/` prefix, which is a no-op for a + // `*.visualstudio.com` or on-prem collection URL. + let resolve = crate::compile::resolve_ado_organization_bash(" "); + let step = format!( + "\ - bash: | set -eo pipefail - # $(System.CollectionUri) is expanded by ADO before bash runs, - # e.g. \"https://dev.azure.com/myorg/\". - _COLLECTION_URI=\"$(System.CollectionUri)\" - _ORG=\"${_COLLECTION_URI#https://dev.azure.com/}\" - _ORG=\"${_ORG%/}\" - echo \"##vso[task.setvariable variable=AW_ADO_ORG]$_ORG\" +{resolve} echo \"##vso[task.setvariable variable=AW_ADO_ORG]$ADO_PROXY_ORGANIZATION\" displayName: \"Resolve ADO organization\" " - .to_string(); + ); (step, "$(AW_ADO_ORG)".to_string()) } }; From a24180542a71891c7d460ef32a8f4d7354467e4c Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 4 Aug 2026 21:47:46 +0100 Subject: [PATCH 21/42] feat(ado-proxy): resolve scope through an organization-relative index Replaces the single pinned organization/project with a ScopeIndex built once at startup: the current scope seeded first, then any additional_scopes the policy carries. Five call sites previously asked "is this the pinned project" in their own way; folding them into one lookup means they cannot drift. Resolution is organization-relative by construction. Asking "is this project in any allowed list" would let a request addressed to organization B name a project granted only in organization A, so every lookup resolves the organization first and tests the project within THAT organization's entry. The same applies to repositories within a project. A project entry carries project_scoped so a repository grant does not imply a project grant: a scope derived from a repos: declaration grants the repository without the work items, pipelines and builds beside it. That mirrors the rule the front matter already has, where a project entry with no repositories: grants project-scoped reads without any repository-scoped read. The policy schema gains additional_scopes with the same fail-closed treatment as the rest of the document - unknown keys at either nesting level are fatal, and an entry naming no projects is refused outright, since in the front matter that would be a request to grant an entire organization. Response filtering now takes the organization the request was addressed to. Response bodies name a project but never an organization, so without it the project check could not stay organization-relative and a project granted in one organization would validate a response from another. Nothing emits additional_scopes yet, so compiled output is unchanged; the Rust emitter follows in scope-model-rust. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- scripts/ado-script/src/ado-proxy/config.ts | 107 +++++++++++ scripts/ado-script/src/ado-proxy/index.ts | 5 + scripts/ado-script/src/ado-proxy/policy.ts | 40 +++-- .../src/ado-proxy/proxy.e2e.test.ts | 3 + scripts/ado-script/src/ado-proxy/response.ts | 78 ++++++-- scripts/ado-script/src/ado-proxy/scope.ts | 170 ++++++++++++++++++ scripts/ado-script/src/ado-proxy/server.ts | 16 ++ 7 files changed, 390 insertions(+), 29 deletions(-) create mode 100644 scripts/ado-script/src/ado-proxy/scope.ts diff --git a/scripts/ado-script/src/ado-proxy/config.ts b/scripts/ado-script/src/ado-proxy/config.ts index 228f7aa1a..90182baf6 100644 --- a/scripts/ado-script/src/ado-proxy/config.ts +++ b/scripts/ado-script/src/ado-proxy/config.ts @@ -14,6 +14,7 @@ import { readFileSync } from "node:fs"; import type { Capability } from "../shared/ado-proxy-catalog.types.gen.js"; import { CATALOG_SCHEMA_VERSION, PROTECTED_HOSTS } from "./catalog.js"; +import { projectScopeDefaults } from "./scope.js"; /** Resolved, validated proxy configuration. */ export interface ProxyConfig { @@ -41,6 +42,31 @@ export interface ProxyConfig { } /** The compiler-emitted policy document. */ +/** One project's grant inside an organization scope. */ +export interface PolicyProjectScope { + /** Project name. */ + readonly project: string; + /** Project id (GUID), when the author supplied one. */ + readonly project_id?: string; + /** + * Whether project-addressed reads are granted. + * + * True when the author named the project in `permissions.read.allow`. False + * for a scope derived from a `repos:` declaration, which grants only the + * repositories it names — declaring a repository is not a request for the + * work items and pipelines beside it. + */ + readonly project_scoped?: boolean; + /** Repository names and/or ids granted within this project. */ + readonly repositories?: readonly string[]; +} + +/** An organization and the projects granted within it. */ +export interface PolicyOrganizationScope { + readonly organization: string; + readonly projects: readonly PolicyProjectScope[]; +} + export interface ProxyPolicy { /** * Catalog version this document was generated against. @@ -59,6 +85,14 @@ export interface ProxyPolicy { readonly repository?: string; /** Repository id (GUID), when the compiler could resolve one. */ readonly repository_id?: string; + /** + * Scopes beyond the current organization and project. + * + * Empty or absent means the agent may read only the scope its own pipeline + * runs in. Entries come from `permissions.read.allow` (which grants the + * project) and from `repos:` declarations (which grant only the repository). + */ + readonly additional_scopes?: readonly PolicyOrganizationScope[]; /** Enabled capability groups; an operation outside these is denied. */ readonly capabilities: readonly Capability[]; /** Hosts whose traffic is TLS-terminated and policy-checked. */ @@ -179,8 +213,80 @@ const KNOWN_POLICY_KEYS: readonly string[] = [ "capabilities", "protected_hosts", "allowed_resource_areas", + "additional_scopes", ]; +/** Keys a single `additional_scopes` entry may carry. */ +const KNOWN_SCOPE_KEYS: readonly string[] = ["organization", "projects"]; + +/** Keys a single project entry may carry. */ +const KNOWN_PROJECT_KEYS: readonly string[] = [ + "project", + "project_id", + "project_scoped", + "repositories", +]; + +/** + * Parse `additional_scopes`, failing closed on anything unrecognized. + * + * Strict for the same reason as the top-level document: a key this bundle does + * not implement means the compiler intended a constraint that would otherwise + * be silently dropped. An entry naming no projects is refused outright — in + * the front matter that would be a request to grant an entire organization, + * and a widening produced by *omitting* a key is exactly the accident this + * proxy exists to prevent. + */ +function parseAdditionalScopes(document: Record): PolicyOrganizationScope[] { + const raw = document.additional_scopes; + if (raw === undefined) return []; + if (!Array.isArray(raw)) fail("policy.additional_scopes must be an array"); + + return raw.map((entry, index) => { + const scope = asRecord(entry, `policy.additional_scopes[${index}]`); + for (const key of Object.keys(scope)) { + if (!KNOWN_SCOPE_KEYS.includes(key)) { + fail(`policy.additional_scopes[${index}] has unknown key ${JSON.stringify(key)}`); + } + } + + const organization = requireString(scope, "organization"); + const projects = scope.projects; + if (!Array.isArray(projects) || projects.length === 0) { + fail( + `policy.additional_scopes[${index}] (${organization}) lists no projects; ` + + "an empty list would grant the whole organization", + ); + } + + return { + organization, + projects: projects.map((projectEntry, projectIndex) => { + const label = `policy.additional_scopes[${index}].projects[${projectIndex}]`; + const project = asRecord(projectEntry, label); + for (const key of Object.keys(project)) { + if (!KNOWN_PROJECT_KEYS.includes(key)) { + fail(`${label} has unknown key ${JSON.stringify(key)}`); + } + } + const repositories = project.repositories; + if (repositories !== undefined && !Array.isArray(repositories)) { + fail(`${label}.repositories must be an array`); + } + if (project.project_scoped !== undefined && typeof project.project_scoped !== "boolean") { + fail(`${label}.project_scoped must be a boolean`); + } + return projectScopeDefaults({ + project: requireString(project, "project"), + project_id: optionalString(project, "project_id"), + project_scoped: project.project_scoped as boolean | undefined, + repositories: (repositories ?? []) as readonly string[], + }); + }), + }; + }); +} + /** * Parse and validate the compiler-emitted policy document. * @@ -251,6 +357,7 @@ export function parsePolicy(raw: string): ProxyPolicy { allowed_resource_areas: Array.isArray(document.allowed_resource_areas) ? requireStringArray(document, "allowed_resource_areas") : [], + additional_scopes: parseAdditionalScopes(document), }; } diff --git a/scripts/ado-script/src/ado-proxy/index.ts b/scripts/ado-script/src/ado-proxy/index.ts index cddbc301b..24e9640b7 100644 --- a/scripts/ado-script/src/ado-proxy/index.ts +++ b/scripts/ado-script/src/ado-proxy/index.ts @@ -30,6 +30,7 @@ import { CaError, publishCaCertificate, readCaMaterials } from "./ca.js"; import { ConfigError, loadConfig, type ProxyConfig } from "./config.js"; import { DecisionLog } from "./log.js"; import { createDirectTlsServer, createProxyServer } from "./server.js"; +import { ScopeIndex } from "./scope.js"; import { TokenSource } from "./token.js"; import { UpstreamError, parseUpstreamProxy } from "./upstream.js"; @@ -77,6 +78,10 @@ export async function run(argv: readonly string[]): Promise { ca, tokens: new TokenSource(ca.token), log: new DecisionLog(config.logDir), + // Built once here rather than per request: request and response validation + // must agree about what is in scope, and rebuilding per call would let the + // two drift. + scopes: ScopeIndex.from(config.policy), }; const server = createProxyServer(deps); const tlsServer = createDirectTlsServer(deps); diff --git a/scripts/ado-script/src/ado-proxy/policy.ts b/scripts/ado-script/src/ado-proxy/policy.ts index e3878e77c..e6d3be949 100644 --- a/scripts/ado-script/src/ado-proxy/policy.ts +++ b/scripts/ado-script/src/ado-proxy/policy.ts @@ -8,6 +8,7 @@ * — an unknown route, an unlisted parameter, an unmatched placeholder — is a * denial, never a pass-through. */ +import { ScopeIndex } from "./scope.js"; import { ApiVersionError, resolveApiVersion, type ApiVersion } from "./api-version.js"; import { DENIED_ROUTE_FAMILIES, OPERATIONS, PROTECTED_HOSTS } from "./catalog.js"; import type { ProxyPolicy } from "./config.js"; @@ -123,15 +124,16 @@ function checkScope( operation: Operation, params: RouteParams, policy: ProxyPolicy, + scopes: ScopeIndex, ): Decision | undefined { const organization = params.org; // Every organization-hosted route carries `{org}`; the SPS fallback route // does not, and is scoped by resource-area id instead. if (operation.host === "current-organization") { - if (organization === undefined || !sameIdentifier(organization, policy.organization)) { + if (organization === undefined || !scopes.hasOrganization(organization)) { return deny( "out-of-scope", - "request names a different organization than the pinned one", + "request names an organization outside the policy", operation.id, ); } @@ -165,10 +167,13 @@ function checkScope( case "current-project-path": { const project = params.project; - if (project === undefined || !isCurrentProject(project, policy)) { + // Organization-relative: the project is looked up *inside* the + // organization named by this request, so a project granted in another + // organization cannot satisfy it. + if (project === undefined || !scopes.allowsProject(organization, project)) { return deny( "out-of-scope", - "request names a different project than the pinned one", + "request names a project outside the policy for this organization", operation.id, ); } @@ -178,17 +183,20 @@ function checkScope( case "current-repository-path": { const project = params.project; const repository = params.repository; - if (project === undefined || !isCurrentProject(project, policy)) { - return deny( - "out-of-scope", - "request names a different project than the pinned one", - operation.id, - ); + if (project === undefined) { + return deny("out-of-scope", "request names no project", operation.id); } - if (repository === undefined || !isCurrentRepository(repository, policy)) { + // A repository grant does not imply a project grant — a `repos:` + // declaration asks for the repository, not the work items and pipelines + // beside it — so this checks the repository within the project rather + // than requiring the project itself to be in scope. + if ( + repository === undefined || + !scopes.allowsRepository(organization, project, repository) + ) { return deny( "out-of-scope", - "request names a different repository than the pinned one", + "request names a repository outside the policy for this project", operation.id, ); } @@ -221,7 +229,11 @@ export interface RequestFacts { * reported as such rather than as a generic "unknown route", which is what an * author needs to see to understand the denial. */ -export function authorize(facts: RequestFacts, policy: ProxyPolicy): Decision { +export function authorize( + facts: RequestFacts, + policy: ProxyPolicy, + scopes: ScopeIndex = ScopeIndex.from(policy), +): Decision { const method = facts.method.toUpperCase(); if (method !== "GET" && method !== "OPTIONS") { return deny("method-not-read", `${method} is not a read method`); @@ -276,7 +288,7 @@ export function authorize(facts: RequestFacts, policy: ProxyPolicy): Decision { const queryDenial = checkQuery(operation, facts.target); if (queryDenial !== undefined) return queryDenial; - const scopeDenial = checkScope(operation, params, policy); + const scopeDenial = checkScope(operation, params, policy, scopes); if (scopeDenial !== undefined) return scopeDenial; return apiVersion === undefined diff --git a/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts index 60e256722..82d48f91c 100644 --- a/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts +++ b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts @@ -36,6 +36,7 @@ import { CATALOG_SCHEMA_VERSION } from "./catalog.js"; import type { ProxyConfig, ProxyPolicy } from "./config.js"; import { DecisionLog } from "./log.js"; import { createDirectTlsServer, HEALTH_PATH, createProxyServer } from "./server.js"; +import { ScopeIndex } from "./scope.js"; import { TokenSource } from "./token.js"; /** @@ -458,6 +459,7 @@ beforeAll(async () => { config, ca: proxyCa, tokens: new TokenSource(CANARY), + scopes: ScopeIndex.from(POLICY), log: new DecisionLog(join(workdir, "decisions")), upstreamCa: upstreamCa.caCertPem, }); @@ -470,6 +472,7 @@ beforeAll(async () => { config, ca: proxyCa, tokens: new TokenSource(CANARY), + scopes: ScopeIndex.from(POLICY), log: new DecisionLog(join(workdir, "decisions")), upstreamCa: upstreamCa.caCertPem, }); diff --git a/scripts/ado-script/src/ado-proxy/response.ts b/scripts/ado-script/src/ado-proxy/response.ts index a258270ee..6042c764e 100644 --- a/scripts/ado-script/src/ado-proxy/response.ts +++ b/scripts/ado-script/src/ado-proxy/response.ts @@ -12,6 +12,7 @@ */ import { PROTECTED_HOSTS } from "./catalog.js"; import type { ProxyPolicy } from "./config.js"; +import { ScopeIndex } from "./scope.js"; import type { Operation, ResponsePolicy } from "../shared/ado-proxy-catalog.types.gen.js"; export type FilterOutcome = @@ -38,14 +39,37 @@ function sameIdentifier(left: unknown, right: string | undefined): boolean { ); } -function isCurrentProject(value: unknown, policy: ProxyPolicy): boolean { - return sameIdentifier(value, policy.project) || sameIdentifier(value, policy.project_id); +/** + * Whether a project named in a response body is in scope. + * + * Organization-relative: the project is resolved inside the organization the + * request was addressed to, so a project granted in a *different* organization + * cannot validate this response. + */ +function inScopeProject( + value: unknown, + scopes: ScopeIndex, + organization: string, +): boolean { + return typeof value === "string" && scopes.allowsProject(organization, value); } -function isCurrentRepository(value: unknown, policy: ProxyPolicy): boolean { +/** + * Whether a repository named in a response body is in scope for its project. + * + * Checked against the repository grant rather than the project grant, because + * a `repos:`-derived scope grants the repository without granting the project. + */ +function inScopeRepository( + value: unknown, + project: unknown, + scopes: ScopeIndex, + organization: string, +): boolean { return ( - sameIdentifier(value, policy.repository) || - sameIdentifier(value, policy.repository_id) + typeof value === "string" && + typeof project === "string" && + scopes.allowsRepository(organization, project, value) ); } @@ -79,6 +103,17 @@ export function filterResponse( * the intercepted MCP path and the `az` broker path. */ selfOrigin: string, + /** Resolved scopes, so response validation agrees with request validation. */ + scopes: ScopeIndex = ScopeIndex.from(policy), + /** + * Organization the request was addressed to. + * + * Response bodies carry a project but not an organization, so it has to come + * from the request. Without it the project check could not stay + * organization-relative, and a project granted in one organization would + * validate a response from another. + */ + organization: string = policy.organization, ): FilterOutcome { const responsePolicy: ResponsePolicy = operation.response; if (responsePolicy === "json") return forward(body); @@ -105,7 +140,8 @@ export function filterResponse( const project = asRecord(entry); return ( project !== undefined && - (isCurrentProject(project.name, policy) || isCurrentProject(project.id, policy)) + (inScopeProject(project.name, scopes, organization) || + inScopeProject(project.id, scopes, organization)) ); }); return reserialize({ count: kept.length, value: kept }); @@ -147,7 +183,9 @@ export function filterResponse( const nested = asRecord(record.project); const fromFields = asRecord(record.fields)?.["System.TeamProject"]; const candidates = [nested?.name, nested?.id, fromFields]; - if (!candidates.some((candidate) => isCurrentProject(candidate, policy))) { + if ( + !candidates.some((candidate) => inScopeProject(candidate, scopes, organization)) + ) { return denyBody("resource belongs to a different project"); } return forward(body); @@ -159,17 +197,27 @@ export function filterResponse( return denyBody("response carried no repository to validate"); } const project = asRecord(repository.project); - if ( - !isCurrentProject(project?.name, policy) && - !isCurrentProject(project?.id, policy) - ) { - return denyBody("resource belongs to a different project"); + // A `repos:`-derived scope grants the repository without granting the + // project, so the repository check is what authorizes this response; the + // project is used only to resolve which grant applies. + const projectName = + typeof project?.name === "string" ? project.name : undefined; + const projectId = typeof project?.id === "string" ? project.id : undefined; + const projectKey = [projectName, projectId].find( + (candidate) => + candidate !== undefined && + (scopes.allowsProject(organization, candidate) || + scopes.allowsRepository(organization, candidate, String(repository.name)) || + scopes.allowsRepository(organization, candidate, String(repository.id))), + ); + if (projectKey === undefined) { + return denyBody("resource belongs to a project outside the policy"); } if ( - !isCurrentRepository(repository.name, policy) && - !isCurrentRepository(repository.id, policy) + !inScopeRepository(repository.name, projectKey, scopes, organization) && + !inScopeRepository(repository.id, projectKey, scopes, organization) ) { - return denyBody("resource belongs to a different repository"); + return denyBody("resource belongs to a repository outside the policy"); } return forward(body); } diff --git a/scripts/ado-script/src/ado-proxy/scope.ts b/scripts/ado-script/src/ado-proxy/scope.ts new file mode 100644 index 000000000..3aa6e343e --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/scope.ts @@ -0,0 +1,170 @@ +/** + * Scope resolution: which organizations, projects and repositories the agent + * may read. + * + * The policy carries a *current* scope — the organization, project and + * repository the pipeline itself runs in, substituted at step time — plus any + * additional scopes the author declared. Rather than testing "current OR + * additional" at each of the five call sites that need it, both are folded + * into one lookup here at startup. Two code paths would drift; one cannot. + * + * ## Resolution is organization-relative + * + * The trap this module exists to avoid: asking "is this project in *any* + * allowed list" would let a request addressed to organization B name a project + * that is only allowed in organization A. Every lookup therefore resolves the + * organization first and tests the project *within that organization's* entry, + * and likewise for repositories within a project. + * + * ## Project scope is not implied by repository scope + * + * A scope derived from a `repos:` declaration grants the repository without + * granting the project: an author who declared a repository asked for a + * repository, not for the work items, pipelines and builds that happen to live + * beside it. `projectScoped` records that distinction, mirroring the rule the + * front matter already has, where a project entry with no `repositories:` + * grants project-scoped reads without any repository-scoped read. + */ +import type { ProxyPolicy, PolicyProjectScope } from "./config.js"; + +/** Case-insensitive identifier comparison, as Azure DevOps treats names. */ +function normalize(value: string | undefined): string | undefined { + return value === undefined || value.trim() === "" ? undefined : value.trim().toLowerCase(); +} + +/** One project's grant within a single organization. */ +interface ProjectGrant { + /** + * Whether project-addressed reads (work items, pipelines, builds) are + * allowed. False for scopes derived from `repos:`, which grant only the + * repositories they name. + */ + readonly projectScoped: boolean; + /** Repository names and ids, lowercased. */ + readonly repositories: ReadonlySet; +} + +/** Resolved, organization-relative view of everything the policy permits. */ +export class ScopeIndex { + /** organization → (project name or id) → grant. */ + private readonly byOrganization: Map>; + + private constructor(byOrganization: Map>) { + this.byOrganization = byOrganization; + } + + /** + * Fold the current scope and every additional scope into one index. + * + * The current scope is seeded first so it cannot be omitted by a malformed + * `additional_scopes`, and a later entry naming the same project can only + * widen it — never revoke `projectScoped`. + */ + static from(policy: ProxyPolicy): ScopeIndex { + const index = new Map>(); + + const add = ( + organization: string | undefined, + project: string | undefined, + projectId: string | undefined, + projectScoped: boolean, + repositories: readonly string[], + ): void => { + const organizationKey = normalize(organization); + if (organizationKey === undefined) return; + const projects = index.get(organizationKey) ?? new Map(); + index.set(organizationKey, projects); + + const repositoryKeys = new Set( + repositories.map(normalize).filter((value): value is string => value !== undefined), + ); + + // A project may be addressed by name or by GUID; both keys point at the + // same grant so a client using either form resolves identically. + for (const key of [normalize(project), normalize(projectId)]) { + if (key === undefined) continue; + const existing = projects.get(key); + projects.set(key, { + projectScoped: projectScoped || (existing?.projectScoped ?? false), + repositories: new Set([...(existing?.repositories ?? []), ...repositoryKeys]), + }); + } + }; + + // The pipeline's own organization, project and repository. Always granted: + // the agent is already running there, with the repository checked out. + add( + policy.organization, + policy.project, + policy.project_id, + true, + [policy.repository, policy.repository_id].filter( + (value): value is string => value !== undefined, + ), + ); + + for (const scope of policy.additional_scopes ?? []) { + for (const project of scope.projects) { + add( + scope.organization, + project.project, + project.project_id, + project.project_scoped ?? true, + project.repositories ?? [], + ); + } + } + + return new ScopeIndex(index); + } + + /** Whether any scope exists in this organization. */ + hasOrganization(organization: string | undefined): boolean { + const key = normalize(organization); + return key !== undefined && this.byOrganization.has(key); + } + + /** + * Whether project-addressed reads are allowed for this organization/project + * pair. + * + * Organization-relative by construction: the project is looked up inside the + * organization's own map, so a project allowed elsewhere does not match here. + */ + allowsProject(organization: string | undefined, project: string | undefined): boolean { + return this.grant(organization, project)?.projectScoped === true; + } + + /** Whether a repository-addressed read is allowed within this project. */ + allowsRepository( + organization: string | undefined, + project: string | undefined, + repository: string | undefined, + ): boolean { + const key = normalize(repository); + if (key === undefined) return false; + return this.grant(organization, project)?.repositories.has(key) === true; + } + + private grant( + organization: string | undefined, + project: string | undefined, + ): ProjectGrant | undefined { + const organizationKey = normalize(organization); + const projectKey = normalize(project); + if (organizationKey === undefined || projectKey === undefined) return undefined; + return this.byOrganization.get(organizationKey)?.get(projectKey); + } +} + +/** Normalize a policy project entry, applying the documented defaults. */ +export function projectScopeDefaults(scope: PolicyProjectScope): PolicyProjectScope { + return { + project: scope.project, + project_id: scope.project_id, + // Absent means the author named a project deliberately, which grants + // project reads. Only a `repos:`-derived scope sets it false. + project_scoped: scope.project_scoped ?? true, + repositories: scope.repositories ?? [], + }; +} diff --git a/scripts/ado-script/src/ado-proxy/server.ts b/scripts/ado-script/src/ado-proxy/server.ts index 8020d7316..04aeacb8d 100644 --- a/scripts/ado-script/src/ado-proxy/server.ts +++ b/scripts/ado-script/src/ado-proxy/server.ts @@ -22,6 +22,7 @@ import { createServer as createNetServer, type Server as NetServer, type Socket import { connect as tlsConnect, createSecureContext, createServer as createTlsServer, type TLSSocket } from "node:tls"; import type { CaMaterials } from "./ca.js"; +import { ScopeIndex } from "./scope.js"; import { canonicalizeHost, isProtectedHost } from "./catalog.js"; import type { ProxyConfig } from "./config.js"; import { sanitizeRequestHeaders, sanitizeResponseHeaders } from "./headers.js"; @@ -75,6 +76,13 @@ export interface ProxyDeps { * trusts rather than disabling verification. */ readonly upstreamCa?: string; + /** + * Resolved organization/project/repository scopes. + * + * Built once at startup from the policy so request and response validation + * cannot disagree about what is in scope. + */ + readonly scopes: ScopeIndex; } /** Send a small JSON error body that no supported client will retry. */ @@ -264,8 +272,14 @@ async function handleProtected( const decision = authorize( { method, host, target, accept: Array.isArray(accept) ? accept[0] : accept }, deps.config.policy, + deps.scopes, ); + // Every organization-hosted route begins `/{org}/…`. Response bodies name a + // project but never an organization, so the request's own organization is + // what keeps response validation organization-relative. + const requestOrganization = target.segments[0]; + if (!decision.allow) { deps.log.write({ ...base, @@ -358,6 +372,8 @@ async function handleProtected( deps.config.policy, body, `https://${host}`, + deps.scopes, + requestOrganization ?? deps.config.policy.organization, ); if (outcome.kind === "deny") { From 4e4987afd965d9d36c1d9bfb67f8919b309e3844 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 4 Aug 2026 22:13:49 +0100 Subject: [PATCH 22/42] test(ado-proxy): prove organization-relative scope resolution Adds the load-bearing negatives for the scope index: a project granted in organization A must not match the same project name in organization B, and a repos-derived scope must allow its repository without opening project-scoped reads beside it. Also guards the fail-closed policy schema at both nesting levels: unknown organization/project keys and an organization naming no projects are rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- .../ado-script/src/ado-proxy/config.test.ts | 73 +++++++++++++ .../ado-script/src/ado-proxy/scope.test.ts | 103 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 scripts/ado-script/src/ado-proxy/scope.test.ts diff --git a/scripts/ado-script/src/ado-proxy/config.test.ts b/scripts/ado-script/src/ado-proxy/config.test.ts index 194b92b08..aadb0c0fd 100644 --- a/scripts/ado-script/src/ado-proxy/config.test.ts +++ b/scripts/ado-script/src/ado-proxy/config.test.ts @@ -89,6 +89,79 @@ describe("parsePolicy", () => { ); }); + it("accepts well-formed additional scopes", () => { + const policy = parsePolicy( + policyJson({ + additional_scopes: [ + { + organization: "fabrikam", + projects: [ + { + project: "Shared", + project_id: "33333333-3333-3333-3333-333333333333", + project_scoped: true, + repositories: ["shared-api"], + }, + ], + }, + ], + }), + ); + + expect(policy.additional_scopes).toEqual([ + { + organization: "fabrikam", + projects: [ + { + project: "Shared", + project_id: "33333333-3333-3333-3333-333333333333", + project_scoped: true, + repositories: ["shared-api"], + }, + ], + }, + ]); + }); + + it("rejects unknown keys at every additional-scope level", () => { + expect(() => + parsePolicy( + policyJson({ + additional_scopes: [ + { + organization: "fabrikam", + projects: [{ project: "Shared" }], + all_projects: true, + }, + ], + }), + ), + ).toThrow(/additional_scopes\[0\].*unknown key/); + + expect(() => + parsePolicy( + policyJson({ + additional_scopes: [ + { + organization: "fabrikam", + projects: [{ project: "Shared", all_repositories: true }], + }, + ], + }), + ), + ).toThrow(/projects\[0\].*unknown key/); + }); + + it("rejects an organization scope naming no projects", () => { + expect(() => + parsePolicy( + policyJson({ + additional_scopes: [{ organization: "fabrikam", projects: [] }], + }), + ), + ).toThrow(/lists no projects/); + }); + it.each([ ["organization", { organization: "" }], ["project", { project: "" }], diff --git a/scripts/ado-script/src/ado-proxy/scope.test.ts b/scripts/ado-script/src/ado-proxy/scope.test.ts new file mode 100644 index 000000000..220573480 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/scope.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { CATALOG_SCHEMA_VERSION } from "./catalog.js"; +import type { ProxyPolicy } from "./config.js"; +import { ScopeIndex } from "./scope.js"; + +const POLICY: ProxyPolicy = { + catalog_version: CATALOG_SCHEMA_VERSION, + organization: "contoso", + project: "Current", + project_id: "11111111-1111-1111-1111-111111111111", + repository: "current-repo", + repository_id: "22222222-2222-2222-2222-222222222222", + additional_scopes: [ + { + organization: "fabrikam", + projects: [ + { + project: "Shared", + project_id: "33333333-3333-3333-3333-333333333333", + project_scoped: true, + repositories: ["shared-api"], + }, + ], + }, + { + organization: "contoso", + projects: [ + { + project: "RepoOnly", + project_scoped: false, + repositories: ["checked-out-repo"], + }, + ], + }, + ], + capabilities: ["discovery", "core", "repos"], + protected_hosts: ["dev.azure.com", "app.vssps.visualstudio.com"], + allowed_resource_areas: [], +}; + +describe("ScopeIndex", () => { + it("seeds the current scope by both names and ids", () => { + const scopes = ScopeIndex.from(POLICY); + + expect(scopes.allowsProject("contoso", "Current")).toBe(true); + expect(scopes.allowsProject("CONTOSO", POLICY.project_id)).toBe(true); + expect(scopes.allowsRepository("contoso", "Current", "current-repo")).toBe(true); + expect( + scopes.allowsRepository("contoso", POLICY.project_id, POLICY.repository_id), + ).toBe(true); + }); + + it("resolves project grants within their organization, never globally", () => { + const scopes = ScopeIndex.from(POLICY); + + expect(scopes.allowsProject("fabrikam", "Shared")).toBe(true); + expect( + scopes.allowsProject("fabrikam", "33333333-3333-3333-3333-333333333333"), + ).toBe(true); + + // The load-bearing negative: a flat "is Shared allowed anywhere?" check + // would return true here and silently widen scope across organizations. + expect(scopes.allowsProject("contoso", "Shared")).toBe(false); + expect(scopes.allowsRepository("contoso", "Shared", "shared-api")).toBe(false); + }); + + it("grants a repos-derived repository without granting its project", () => { + const scopes = ScopeIndex.from(POLICY); + + expect(scopes.allowsRepository("contoso", "RepoOnly", "checked-out-repo")).toBe( + true, + ); + expect(scopes.allowsProject("contoso", "RepoOnly")).toBe(false); + }); + + it("merges duplicate grants without allowing a later entry to revoke scope", () => { + const scopes = ScopeIndex.from({ + ...POLICY, + additional_scopes: [ + { + organization: "fabrikam", + projects: [ + { + project: "Shared", + project_scoped: true, + repositories: ["one"], + }, + { + project: "Shared", + project_scoped: false, + repositories: ["two"], + }, + ], + }, + ], + }); + + expect(scopes.allowsProject("fabrikam", "Shared")).toBe(true); + expect(scopes.allowsRepository("fabrikam", "Shared", "one")).toBe(true); + expect(scopes.allowsRepository("fabrikam", "Shared", "two")).toBe(true); + }); +}); From d7e4bc35072c176ef8a68e24740ed75fa103d37c Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 4 Aug 2026 22:26:16 +0100 Subject: [PATCH 23/42] feat(ado-proxy): emit explicit cross-organization read scopes Lowers permissions.read.allow into the bundle's organization-relative additional_scopes tree. Each explicitly named project carries project_scoped=true, while repository-only grants derived from repos: will use false in the follow-up change. Adds optional project-id to the front-matter project scope, typed as a validated GUID. Azure DevOps clients may address an additional project by a cached GUID; without an author-supplied id, name-form requests work and GUID-form requests fail closed. The compiler now emits additional_scopes explicitly even when empty, so the current compiler never leaves that part of the policy undecided. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- src/ado_proxy/policy.rs | 120 ++++++++++++++++++++++++++++++++++++++++ src/compile/types.rs | 18 ++++++ 2 files changed, 138 insertions(+) diff --git a/src/ado_proxy/policy.rs b/src/ado_proxy/policy.rs index 5976df7ae..b6a7bf1a6 100644 --- a/src/ado_proxy/policy.rs +++ b/src/ado_proxy/policy.rs @@ -98,10 +98,37 @@ pub struct PolicyDocument { pub repository: Option, #[serde(skip_serializing_if = "Option::is_none")] pub repository_id: Option, + /// Scopes beyond the pipeline's own organization/project/repository. + /// + /// Empty is emitted rather than omitted so the compiler makes an explicit + /// statement that there are no additions. The bundle defaults an absent + /// value to empty for compatibility with older policies. + pub additional_scopes: Vec, pub capabilities: Vec<&'static str>, pub protected_hosts: Vec<&'static str>, } +/// One explicitly allowed organization and its projects. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PolicyOrganizationScope { + pub organization: String, + pub projects: Vec, +} + +/// A project grant within one organization. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PolicyProjectScope { + pub project: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + /// True because `permissions.read.allow` names this project deliberately. + /// + /// A later `repos:`-derived grant uses false so declaring a repository does + /// not unlock the work items, builds and pipelines beside it. + pub project_scoped: bool, + pub repositories: Vec, +} + impl PolicyDocument { /// Build the document from the compiler's own configuration. /// @@ -140,6 +167,7 @@ impl PolicyDocument { project_id: Some(PROJECT_ID_PLACEHOLDER.to_string()), repository: Some(REPOSITORY_PLACEHOLDER.to_string()), repository_id: Some(REPOSITORY_ID_PLACEHOLDER.to_string()), + additional_scopes: Self::explicit_additional_scopes(front_matter), capabilities, // Every catalogued host must appear: one the bundle policed but // the document omitted would be byte-tunnelled to Squid instead, @@ -148,6 +176,44 @@ impl PolicyDocument { } } + /// Lower `permissions.read.allow` into the exact organization-relative tree + /// the bundle validates. + /// + /// The nesting is preserved rather than flattened: a project granted in + /// organization A must never match the same project name in organization B. + fn explicit_additional_scopes(front_matter: &FrontMatter) -> Vec { + let options = front_matter + .permissions + .as_ref() + .and_then(|permissions| permissions.read.as_ref()) + .and_then(crate::compile::types::ReadPermissionConfig::options); + + options + .into_iter() + .flat_map(|options| &options.allow) + .map(|scope| PolicyOrganizationScope { + organization: scope.organization.as_str().to_string(), + projects: scope + .projects + .iter() + .map(|project| PolicyProjectScope { + project: project.project.as_str().to_string(), + project_id: project + .project_id + .as_ref() + .map(|value| value.as_str().to_string()), + project_scoped: true, + repositories: project + .repositories + .iter() + .map(|repository| repository.as_str().to_string()) + .collect(), + }) + .collect(), + }) + .collect() + } + /// Render as the JSON the bundle reads from `--policy-file`. pub fn to_json(&self) -> String { serde_json::to_string_pretty(self) @@ -176,6 +242,28 @@ mod tests { .0 } + fn with_additional_scope() -> FrontMatter { + crate::compile::parse_markdown( + r#"--- +name: t +description: x +permissions: + read: + service-connection: my-read-sc + capabilities: [core, repos] + allow: + - organization: fabrikam + projects: + - project: Shared + project-id: 33333333-3333-3333-3333-333333333333 + repositories: [shared-api] +--- +"#, + ) + .unwrap() + .0 + } + #[test] fn discovery_is_present_even_when_unrequested() { let document = PolicyDocument::new(&with_capabilities("repos")); @@ -241,6 +329,38 @@ mod tests { } } + #[test] + fn explicit_allow_scopes_preserve_the_organization_project_tree() { + let document = PolicyDocument::new(&with_additional_scope()); + + assert_eq!( + document.additional_scopes, + vec![PolicyOrganizationScope { + organization: "fabrikam".to_string(), + projects: vec![PolicyProjectScope { + project: "Shared".to_string(), + project_id: Some( + "33333333-3333-3333-3333-333333333333".to_string() + ), + project_scoped: true, + repositories: vec!["shared-api".to_string()], + }], + }] + ); + } + + #[test] + fn additional_scopes_are_emitted_even_when_empty() { + // Explicit `[]` is the compiler stating there are no additions. The + // bundle also accepts absent for compatibility with old policies, but + // the current compiler should never leave this field undecided. + let json = PolicyDocument::new(&plain()).to_json(); + assert!( + json.contains("\"additional_scopes\": []"), + "additional_scopes must be explicit: {json}" + ); + } + #[test] fn catalog_version_matches_the_catalog() { let document = PolicyDocument::new(&plain()); diff --git a/src/compile/types.rs b/src/compile/types.rs index 4c10dc1f1..e9cb3c659 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -2003,6 +2003,15 @@ pub struct AdoReadOrganizationScope { #[serde(deny_unknown_fields)] pub struct AdoReadProjectScope { pub project: crate::secure::AdoProject, + /// Optional Azure DevOps project GUID. + /// + /// Additional scopes are known only from front matter, not from a runtime + /// discovery call. Clients — especially `az` — may address a project by a + /// cached GUID rather than by name, so authors can provide the GUID to make + /// both forms resolve. Without it, name-form requests still work and a + /// GUID-form request fails closed. + #[serde(default, rename = "project-id")] + pub project_id: Option, /// Repositories to allow within this project. /// /// May be omitted. Unlike an organization with no projects, this is not a @@ -4046,6 +4055,7 @@ read: - organization: other-org projects: - project: Other Project + project-id: 11111111-1111-1111-1111-111111111111 repositories: [Repo One, 01234567-89ab-cdef-0123-456789abcdef] "#; let pc: PermissionsConfig = serde_yaml::from_str(yaml).unwrap(); @@ -4066,6 +4076,13 @@ read: options.allow[0].projects[0].project.as_str(), "Other Project" ); + assert_eq!( + options.allow[0].projects[0] + .project_id + .as_ref() + .map(|value| value.as_str()), + Some("11111111-1111-1111-1111-111111111111") + ); assert_eq!( options.allow[0].projects[0].repositories[0].as_str(), "Repo One" @@ -4077,6 +4094,7 @@ read: for yaml in [ "read:\n service-connection: sc\n allow:\n - organization: 'bad/org'", "read:\n service-connection: sc\n allow:\n - organization: org\n projects:\n - project: Project\n repositories: ['../repo']", + "read:\n service-connection: sc\n allow:\n - organization: org\n projects:\n - project: Project\n project-id: not-a-guid", "read:\n service-connection: sc\n unknown: value", ] { assert!( From 4fbf8aeed6b1cfec49cae1fb1c53656826bbf2ce Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 4 Aug 2026 22:33:42 +0100 Subject: [PATCH 24/42] feat(ado-proxy): accept structured permissions.read policies Removes the blanket rejection of the permissions.read object form now that capabilities and allow scopes are consumed by the compiler-owned policy document and enforced by the bundle. Structural validation stays on the compile path: an allow entry naming an organization with no projects still fails closed, because omission would otherwise request an entire organization. The Azure DevOps MCP fixture now uses the object form and asserts that its cross-organization scope survives into compiled policy JSON. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- src/compile/common.rs | 57 ++++++++++-------------- tests/compiler_tests.rs | 11 +++++ tests/fixtures/azure-devops-mcp-agent.md | 10 ++++- 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index 1858bbd20..5d0e7cadf 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -494,13 +494,13 @@ pub fn validate_proxied_timeout(front_matter: &FrontMatter, timeout_minutes: u32 ) } -/// Reject explicit Stage 1 read-policy options until the credential-isolated -/// proxy enforces them. +/// Validate explicit Stage 1 read-policy options before policy emission. /// -/// Deserializing the object form now lets the typed schema and validation -/// evolve independently, but compiling it as the legacy scalar behavior would -/// silently ignore scope/capability restrictions. Fail closed until the proxy -/// wiring consumes the policy. +/// The object form is now consumed by [`crate::ado_proxy::policy::PolicyDocument`]: +/// capabilities narrow the operation catalog and `allow:` lowers into the +/// bundle's organization-relative scope tree. Structural validation remains on +/// the compile path so a widening produced by omission — such as naming an +/// organization with no projects — fails before any pipeline is emitted. pub fn validate_permissions_read_policy(front_matter: &FrontMatter) -> Result<()> { let Some(options) = front_matter .permissions @@ -511,31 +511,7 @@ pub fn validate_permissions_read_policy(front_matter: &FrontMatter) -> Result<() return Ok(()); }; - // Run the structural rules first even though the object form is refused - // below. They are the rules that will govern the policy document once the - // proxy is wired, so keeping them on the live path means they are exercised - // by every fixture that uses the object form rather than only by unit - // tests — a scope mistake cannot lie dormant until the day we enable it. - options.validate()?; - - // Echo back what was requested. Without this the author cannot tell whether - // the compiler understood their policy or choked on the first key. - let requested = if options.capabilities.is_empty() { - "the default capability set".to_string() - } else { - options - .capabilities - .iter() - .map(|capability| capability.to_catalog().as_str()) - .collect::>() - .join(", ") - }; - - anyhow::bail!( - "permissions.read object form requires the credential-isolated Azure DevOps proxy, \ - which is not enabled in this compiler yet (requested: {requested}). Use the scalar \ - service-connection shorthand for the current trusted MCP behavior." - ) + options.validate() } /// Validate the `variable-groups:` front-matter block (issue #1385). @@ -5682,8 +5658,7 @@ safe-outputs: } #[test] - fn test_validate_permissions_read_policy_allows_scalar_and_rejects_object() { - + fn test_validate_permissions_read_policy_accepts_scalar_and_object() { let (scalar, _) = parse_markdown( "---\nname: test\ndescription: test\npermissions:\n read: my-read-sc\n---\n", ) @@ -5694,10 +5669,24 @@ safe-outputs: "---\nname: test\ndescription: test\npermissions:\n read:\n service-connection: my-read-sc\n capabilities: [repos]\n---\n", ) .unwrap(); + validate_permissions_read_policy(&object).unwrap(); + } + + #[test] + fn test_validate_permissions_read_policy_rejects_org_without_projects() { + let (object, _) = parse_markdown( + "---\nname: test\ndescription: test\npermissions:\n read:\n \ + service-connection: my-read-sc\n allow:\n - organization: fabrikam\n---\n", + ) + .unwrap(); + let error = validate_permissions_read_policy(&object) .unwrap_err() .to_string(); - assert!(error.contains("credential-isolated Azure DevOps proxy")); + assert!( + error.contains("lists no projects"), + "must explain the widening omission: {error}" + ); } /// The proxy holds one non-renewable bearer, so a run must not be able to diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 629a22fbb..ec8f6aaa1 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1958,6 +1958,17 @@ fn test_fixture_azure_devops_mcp_compiled_output() { compiled.contains("--add-host"), "the MCP must be redirected at the policy engine" ); + assert!( + compiled.contains("\"additional_scopes\": ["), + "the object-form read policy must emit its scope tree" + ); + assert!( + compiled.contains("\"organization\": \"fabrikam\"") + && compiled.contains("\"project\": \"Shared\"") + && compiled.contains("\"project_scoped\": true") + && compiled.contains("\"shared-api\""), + "the explicit cross-organization scope must survive compilation" + ); let _ = fs::remove_dir_all(&temp_dir); } diff --git a/tests/fixtures/azure-devops-mcp-agent.md b/tests/fixtures/azure-devops-mcp-agent.md index 7c2b2f249..ecb834c1a 100644 --- a/tests/fixtures/azure-devops-mcp-agent.md +++ b/tests/fixtures/azure-devops-mcp-agent.md @@ -11,7 +11,15 @@ tools: - wit_create_work_item - wit_my_work_items permissions: - read: my-read-arm-connection + read: + service-connection: my-read-arm-connection + capabilities: [core, repos, pipelines, boards] + allow: + - organization: fabrikam + projects: + - project: Shared + project-id: 33333333-3333-3333-3333-333333333333 + repositories: [shared-api] write: my-write-arm-connection safe-outputs: create-work-item: From 45995cffa071ecfc920af296b33ed71ad76d591a Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 4 Aug 2026 22:40:44 +0100 Subject: [PATCH 25/42] fix(ado-proxy): require a read token source when proxying ADO Fails compilation when tools.azure-devops is enabled without permissions.read. Previously no SC_READ_TOKEN was minted, so the engine reached startup with an empty bearer and failed on a base64 error - fail-closed, but too late and with no actionable explanation. The error names the missing front-matter key and explains that the token is delivered only to the trusted proxy, not to the agent or MCP. Explicitly disabling the tool remains valid and no longer counts as proxy enablement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- src/compile/common.rs | 50 ++++++++++++++++++- tests/compiler_tests.rs | 35 +++++++++++++ .../fixtures/azure-devops-mcp-missing-read.md | 9 ++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/azure-devops-mcp-missing-read.md diff --git a/src/compile/common.rs b/src/compile/common.rs index 5d0e7cadf..398bfde0d 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -46,6 +46,37 @@ pub async fn atomic_write(path: &Path, contents: &str) -> Result<()> { .context("atomic_write task panicked")? } +#[test] +fn test_validate_permissions_read_policy_requires_token_source_when_proxied() { + let (missing, _) = parse_markdown( + "---\nname: test\ndescription: test\ntools:\n azure-devops:\n org: contoso\n---\n", + ) + .unwrap(); + + let error = validate_permissions_read_policy(&missing) + .unwrap_err() + .to_string(); + assert!( + error.contains("tools.azure-devops requires `permissions.read`"), + "message must name the missing configuration: {error}" + ); + assert!( + error.contains("agent and Azure DevOps MCP receive no real credential"), + "message must explain custody rather than asking the author to expose a token: {error}" + ); +} + +#[test] +fn test_validate_permissions_read_policy_ignores_explicitly_disabled_tool() { + let (disabled, _) = parse_markdown( + "---\nname: test\ndescription: test\ntools:\n azure-devops: false\n---\n", + ) + .unwrap(); + + validate_permissions_read_policy(&disabled).unwrap(); + assert!(!ado_proxy_enabled(&disabled)); +} + /// Returns the directory in which the atomic tempfile should be created for a /// write to `path`. The tempfile must live on the same filesystem as `path` /// so that the final `persist()` rename is atomic (EXDEV guard). @@ -502,6 +533,22 @@ pub fn validate_proxied_timeout(front_matter: &FrontMatter, timeout_minutes: u32 /// the compile path so a widening produced by omission — such as naming an /// organization with no projects — fails before any pipeline is emitted. pub fn validate_permissions_read_policy(front_matter: &FrontMatter) -> Result<()> { + if ado_proxy_enabled(front_matter) + && front_matter + .permissions + .as_ref() + .and_then(|permissions| permissions.read.as_ref()) + .is_none() + { + anyhow::bail!( + "tools.azure-devops requires `permissions.read` so the trusted ado-proxy \ + process can acquire an Azure DevOps token. Add either \ + `permissions:\\n read: ` or the object form \ + with `service-connection:`. The token is delivered only to the proxy; \ + the agent and Azure DevOps MCP receive no real credential." + ); + } + let Some(options) = front_matter .permissions .as_ref() @@ -1670,7 +1717,8 @@ pub fn ado_proxy_enabled(front_matter: &FrontMatter) -> bool { front_matter .tools .as_ref() - .is_some_and(|tools| tools.azure_devops.is_some()) + .and_then(|tools| tools.azure_devops.as_ref()) + .is_some_and(crate::compile::types::AzureDevOpsToolConfig::is_enabled) } /// Directory the generated `az` wrapper is installed into inside the sandbox. diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index ec8f6aaa1..75fc234f7 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1862,6 +1862,41 @@ Call the noop tool exactly once. // ==================== Azure DevOps MCP Integration Tests ==================== +#[test] +fn test_fixture_azure_devops_mcp_requires_read_permission() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp directory"); + let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("azure-devops-mcp-missing-read.md"); + let output_path = temp_dir.path().join("missing-read.lock.yml"); + + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args([ + "compile", + fixture_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run compiler"); + + assert!( + !output.status.success(), + "compilation must fail before emitting a proxy with no token source" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("tools.azure-devops requires `permissions.read`"), + "error must name the missing front-matter key: {stderr}" + ); + assert!( + !output_path.exists(), + "a failed validation must not leave a success-shaped pipeline" + ); +} + /// Test that the Azure DevOps MCP fixture compiles successfully with no unreplaced markers #[test] fn test_fixture_azure_devops_mcp_compiled_output() { diff --git a/tests/fixtures/azure-devops-mcp-missing-read.md b/tests/fixtures/azure-devops-mcp-missing-read.md new file mode 100644 index 000000000..ae990e16d --- /dev/null +++ b/tests/fixtures/azure-devops-mcp-missing-read.md @@ -0,0 +1,9 @@ +--- +name: "Azure DevOps MCP Missing Read Permission" +description: "Negative fixture: proxy enabled without a token source" +tools: + azure-devops: + org: myorg +--- + +List the Azure DevOps projects. From ff3d6132deb1984522d481d18983c78188b230c8 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 4 Aug 2026 22:54:06 +0100 Subject: [PATCH 26/42] feat(ado-proxy): derive repository-only scopes from repos Treats type: git repository resources as implicit read grants for the named Azure Repos repository. The build identity already resolves the resource and, when checked out, the working tree is already in the sandbox; denying its PR, branch and commit metadata would be incoherent. The grant is repository-only: project_scoped=false prevents the repository declaration from opening the work items, builds and pipelines beside it. Non-ADO repository types and bare current-project names grant nothing, while checkout: false still counts because the ADO resource remains explicitly declared and resolved. Also corrects the front-matter docs: Azure Repos type: git names are project/repo, not organization/repo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/front-matter.md | 6 +-- src/ado_proxy/policy.rs | 101 +++++++++++++++++++++++++++++++++++++++- src/compile/types.rs | 7 ++- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/docs/front-matter.md b/docs/front-matter.md index 627cdd67c..e607edc7d 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -36,9 +36,9 @@ pool: # Optional pool configuration # conclusion: # vmImage: ubuntu-22.04 repos: # compact repository declarations (replaces repositories: + checkout:) - - my-org/my-repo # shorthand: alias="my-repo", type=git, ref=refs/heads/main, checkout=true - - reponame=my-org/another-repo # shorthand with explicit alias - - name: my-org/templates # object form for full control + - MyProject/my-repo # shorthand: alias="my-repo", type=git, ref=refs/heads/main, checkout=true + - reponame=MyProject/another-repo # shorthand with explicit alias + - name: MyProject/templates # object form for full control ref: refs/heads/release/2.x checkout: false # declared as resource only, not checked out by the agent tools: # optional tool configuration diff --git a/src/ado_proxy/policy.rs b/src/ado_proxy/policy.rs index b6a7bf1a6..0abff3760 100644 --- a/src/ado_proxy/policy.rs +++ b/src/ado_proxy/policy.rs @@ -167,7 +167,7 @@ impl PolicyDocument { project_id: Some(PROJECT_ID_PLACEHOLDER.to_string()), repository: Some(REPOSITORY_PLACEHOLDER.to_string()), repository_id: Some(REPOSITORY_ID_PLACEHOLDER.to_string()), - additional_scopes: Self::explicit_additional_scopes(front_matter), + additional_scopes: Self::additional_scopes(front_matter), capabilities, // Every catalogued host must appear: one the bundle policed but // the document omitted would be byte-tunnelled to Squid instead, @@ -181,6 +181,12 @@ impl PolicyDocument { /// /// The nesting is preserved rather than flattened: a project granted in /// organization A must never match the same project name in organization B. + fn additional_scopes(front_matter: &FrontMatter) -> Vec { + let mut scopes = Self::explicit_additional_scopes(front_matter); + scopes.extend(Self::repository_scopes(front_matter)); + scopes + } + fn explicit_additional_scopes(front_matter: &FrontMatter) -> Vec { let options = front_matter .permissions @@ -214,6 +220,49 @@ impl PolicyDocument { .collect() } + /// Derive repository-only grants from Azure Repos resources. + /// + /// For `type: git`, ADO's repository resource name is `project/repo`. + /// Declaring it already authorizes the build identity to resolve the + /// resource, and when checked out its entire working tree is in the + /// sandbox. Denying API metadata for that same repository would be + /// incoherent, so the repository is implicitly readable. + /// + /// The project itself is *not* granted: declaring one repository is not a + /// request for the work items, pipelines and builds beside it. + fn repository_scopes(front_matter: &FrontMatter) -> Vec { + let projects = front_matter + .repositories + .iter() + .filter(|repository| repository.repo_type.eq_ignore_ascii_case("git")) + .filter_map(|repository| { + let (project, name) = repository.name.split_once('/')?; + if project.is_empty() || name.is_empty() { + return None; + } + Some(PolicyProjectScope { + project: project.to_string(), + project_id: None, + project_scoped: false, + repositories: vec![name.to_string()], + }) + }) + .collect::>(); + + if projects.is_empty() { + Vec::new() + } else { + vec![PolicyOrganizationScope { + // `type: git` resources are same-organization by construction. + // Cross-organization repositories require an explicit + // `permissions.read.allow` entry and, potentially, a different + // credential tenant. + organization: ORGANIZATION_PLACEHOLDER.to_string(), + projects, + }] + } + } + /// Render as the JSON the bundle reads from `--policy-file`. pub fn to_json(&self) -> String { serde_json::to_string_pretty(self) @@ -264,6 +313,30 @@ permissions: .0 } + fn with_repository_resources() -> FrontMatter { + let mut front_matter = crate::compile::parse_markdown( + r#"--- +name: t +description: x +repos: + - name: Shared/shared-api + checkout: false + - name: owner/github-repo + type: github + - name: local-repo +--- +"#, + ) + .unwrap() + .0; + let (repositories, checkout, checkout_fetch) = + crate::compile::resolve_repos(&front_matter).unwrap(); + front_matter.repositories = repositories; + front_matter.checkout = checkout; + front_matter.checkout_fetch = checkout_fetch; + front_matter + } + #[test] fn discovery_is_present_even_when_unrequested() { let document = PolicyDocument::new(&with_capabilities("repos")); @@ -361,6 +434,32 @@ permissions: ); } + #[test] + fn azure_repos_resources_grant_the_repository_but_not_the_project() { + let document = PolicyDocument::new(&with_repository_resources()); + + assert_eq!( + document.additional_scopes, + vec![PolicyOrganizationScope { + organization: ORGANIZATION_PLACEHOLDER.to_string(), + projects: vec![PolicyProjectScope { + project: "Shared".to_string(), + project_id: None, + project_scoped: false, + repositories: vec!["shared-api".to_string()], + }], + }] + ); + } + + #[test] + fn non_ado_and_bare_repository_resources_grant_nothing() { + let emitted = PolicyDocument::new(&with_repository_resources()).to_json(); + + assert!(!emitted.contains("github-repo"), "{emitted}"); + assert!(!emitted.contains("local-repo"), "{emitted}"); + } + #[test] fn catalog_version_matches_the_catalog() { let document = PolicyDocument::new(&plain()); diff --git a/src/compile/types.rs b/src/compile/types.rs index e9cb3c659..7ab4f01d5 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -2280,7 +2280,10 @@ fn default_ref() -> String { /// Object form for a `repos:` entry. #[derive(Debug, Deserialize, Clone)] pub struct RepoEntry { - /// Full repo name in the form `org/repo` (maps to ADO `name:`). + /// Full Azure Repos name in the form `project/repo` (maps to ADO `name:`). + /// + /// Other repository resource types may use a different provider-specific + /// shape; only `type: git` participates in ado-proxy scope derivation. pub name: String, /// Optional alias (maps to ADO `repository:`). Defaults to the last segment of `name`. #[serde(default)] @@ -2347,7 +2350,7 @@ impl CheckoutFetchOpts { /// A single item in the `repos:` list — either a string shorthand or an object. #[derive(Debug, Clone)] pub enum ReposItem { - /// String shorthand: `"org/repo"` or `"alias=org/repo"`. + /// String shorthand: `"project/repo"` or `"alias=project/repo"`. Shorthand(String), /// Full object form with explicit fields. Full(RepoEntry), From 157c14b5462f2c48fd1211ae928a1573f15f1677 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 4 Aug 2026 23:23:04 +0100 Subject: [PATCH 27/42] test(ado-proxy): prove scoped reads across compiler and bundle Makes the integration fixture exercise a narrowed capability set, an explicit cross-organization project grant and an implicit repository-only grant. Compilation asserts all three surfaces agree: policy JSON, the az wrapper and the agent prompt expose only discovery/core/repos. Bundle authorization tests prove the load-bearing boundaries at route level: an explicitly granted cross-organization project works by name and GUID; the same project name in another organization is denied out-of-scope; a repos-derived repository is readable while project and build reads beside it remain denied; and projects outside every scope are denied. The built ado-proxy bundle was also started in Docker with the exact policy extracted from compiled YAML. It reported capabilities=discovery,core,repos, published its CA, and accepted both the explicit fabrikam/Shared scope and the implicit contoso/LocalProject repository-only scope at startup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- .../ado-script/src/ado-proxy/policy.test.ts | 87 +++++++++++++++++++ tests/compiler_tests.rs | 45 ++++++++++ tests/fixtures/azure-devops-mcp-agent.md | 8 +- 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/scripts/ado-script/src/ado-proxy/policy.test.ts b/scripts/ado-script/src/ado-proxy/policy.test.ts index a57e7de24..cb15c48fd 100644 --- a/scripts/ado-script/src/ado-proxy/policy.test.ts +++ b/scripts/ado-script/src/ado-proxy/policy.test.ts @@ -17,6 +17,33 @@ const POLICY: ProxyPolicy = { allowed_resource_areas: ["79134c72-4a58-4b42-976c-04e7115f32bf"], }; +const MULTI_SCOPE_POLICY: ProxyPolicy = { + ...POLICY, + additional_scopes: [ + { + organization: "fabrikam", + projects: [ + { + project: "Shared", + project_id: "33333333-3333-3333-3333-333333333333", + project_scoped: true, + repositories: ["shared-api"], + }, + ], + }, + { + organization: "contoso", + projects: [ + { + project: "RepoOnly", + project_scoped: false, + repositories: ["implicit-api"], + }, + ], + }, + ], +}; + function decide( method: string, url: string, @@ -235,6 +262,66 @@ describe("authorize — denials", () => { }); }); +describe("authorize — additional organization-relative scopes", () => { + it("allows a project explicitly granted in another organization", () => { + expect( + decide("GET", "/fabrikam/_apis/projects/Shared?api-version=7.1", { + policy: MULTI_SCOPE_POLICY, + }).allow, + ).toBe(true); + expect( + decide( + "GET", + "/fabrikam/_apis/projects/33333333-3333-3333-3333-333333333333?api-version=7.1", + { policy: MULTI_SCOPE_POLICY }, + ).allow, + ).toBe(true); + }); + + it("denies org B naming a project granted only in org A", () => { + // This must reach the scope check and fail there. A flat global project + // membership test would silently allow it. + const decision = decide( + "GET", + "/contoso/_apis/projects/Shared?api-version=7.1", + { policy: MULTI_SCOPE_POLICY }, + ); + expectDeny(decision, "out-of-scope"); + }); + + it("allows a repos-derived repository without opening its project", () => { + expect( + decide( + "GET", + "/contoso/RepoOnly/_apis/git/repositories/implicit-api/refs?api-version=7.1&filter=heads", + { policy: MULTI_SCOPE_POLICY }, + ).allow, + ).toBe(true); + + expectDeny( + decide("GET", "/contoso/_apis/projects/RepoOnly?api-version=7.1", { + policy: MULTI_SCOPE_POLICY, + }), + "out-of-scope", + ); + expectDeny( + decide("GET", "/contoso/RepoOnly/_apis/build/builds?api-version=7.1", { + policy: MULTI_SCOPE_POLICY, + }), + "out-of-scope", + ); + }); + + it("denies projects outside every scope", () => { + expectDeny( + decide("GET", "/fabrikam/_apis/projects/Payroll?api-version=7.1", { + policy: MULTI_SCOPE_POLICY, + }), + "out-of-scope", + ); + }); +}); + describe("authorize — response-scoped operations", () => { it("allows the org-level pull-request read that az repos pr show needs", () => { // Its URL carries no project or repository, so the scope check happens on diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 75fc234f7..ba82a4ee1 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1930,6 +1930,23 @@ fn test_fixture_azure_devops_mcp_compiled_output() { let compiled = fs::read_to_string(&output_path).expect("Should read compiled output"); + let policy_marker = "cat > \"$PROXY_DIR/policy/policy.json\" <<'ADO_PROXY_POLICY_EOF'\n"; + let policy_start = compiled + .find(policy_marker) + .map(|index| index + policy_marker.len()) + .expect("compiled pipeline must carry an ado-proxy policy document"); + let policy_tail = &compiled[policy_start..]; + let policy_end = policy_tail + .find("\n ADO_PROXY_POLICY_EOF") + .expect("compiled policy heredoc must terminate"); + let policy_json = policy_tail[..policy_end] + .lines() + .map(|line| line.strip_prefix(" ").unwrap_or(line)) + .collect::>() + .join("\n"); + let policy: serde_json::Value = + serde_json::from_str(&policy_json).expect("compiled policy must be valid JSON"); + // No unreplaced template markers (except ADO ${{ }} expressions) for line in compiled.lines() { let stripped = line.replace("${{", ""); @@ -2004,6 +2021,34 @@ fn test_fixture_azure_devops_mcp_compiled_output() { && compiled.contains("\"shared-api\""), "the explicit cross-organization scope must survive compilation" ); + assert!( + compiled.contains("\"organization\": \"${ADO_PROXY_ORGANIZATION}\"") + && compiled.contains("\"project\": \"LocalProject\"") + && compiled.contains("\"project_scoped\": false") + && compiled.contains("\"implicit-api\""), + "the Azure Repos resource must emit a repository-only scope" + ); + assert!( + !compiled.contains("\"github-only\""), + "a GitHub repository resource must not grant Azure DevOps scope" + ); + assert_eq!( + policy["capabilities"], + serde_json::json!(["discovery", "core", "repos"]), + "only explicitly selected capabilities plus discovery may be emitted" + ); + assert!( + compiled.contains("case \" devops repos rest \" in"), + "the az wrapper must narrow to the same capability set" + ); + assert!( + compiled.contains("**Available** — `az devops`, `az repos`, `az rest`"), + "the prompt must advertise the same capability set" + ); + assert!( + !compiled.contains("**Available** — `az devops`, `az repos`, `az pipelines`"), + "an ungranted capability must not leak into the prompt" + ); let _ = fs::remove_dir_all(&temp_dir); } diff --git a/tests/fixtures/azure-devops-mcp-agent.md b/tests/fixtures/azure-devops-mcp-agent.md index ecb834c1a..a5c01099c 100644 --- a/tests/fixtures/azure-devops-mcp-agent.md +++ b/tests/fixtures/azure-devops-mcp-agent.md @@ -1,6 +1,12 @@ --- name: "Azure DevOps MCP Agent" description: "Agent with Azure DevOps MCP via first-class tool integration" +repos: + - name: LocalProject/implicit-api + checkout: false + - name: owner/github-only + type: github + checkout: false tools: azure-devops: org: myorg @@ -13,7 +19,7 @@ tools: permissions: read: service-connection: my-read-arm-connection - capabilities: [core, repos, pipelines, boards] + capabilities: [core, repos] allow: - organization: fabrikam projects: From ed4c14e20c1a7df68f567fa1f62a978f69b37dd0 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 4 Aug 2026 23:31:45 +0100 Subject: [PATCH 28/42] docs(ado-proxy): document scoped credential-isolated reads Documents the live permissions.read grammar: scalar shorthand, capability narrowing, additive organization-relative allow scopes, optional project GUIDs and implicit repository-only grants from type: git repos entries. Clarifies the two hard limits: cross-organization access uses one service-connection identity within one AAD tenant, while cross-tenant reads need another credential and are unsupported; and a missing project-id permits name-form calls while cached GUID-form calls fail closed. Replaces stale pre-proxy descriptions of the MCP token mapping, npx startup, az authentication, rotating token files and AWF-managed sidecars with the implemented topology: stdin-only bearer custody in ado-proxy, sentinel clients, internal MCP network, CONNECT-based az wrapper, one-shot token with a 50-minute compile-time bound, and a host-started node:20-slim container attached by AWF. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 130 +++++++++++++++++---------------------- docs/ado-script.md | 15 +++-- docs/cli.md | 3 +- docs/front-matter.md | 11 +++- docs/mcp.md | 10 +-- docs/network.md | 50 ++++++++++----- docs/tools.md | 22 ++++--- src/compile/types.rs | 3 +- 8 files changed, 133 insertions(+), 111 deletions(-) diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index f0d06ae06..f8fd1ea51 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -1,10 +1,9 @@ # Credential-Isolated Azure DevOps Proxy (`ado-proxy`) -_Security contract and implementation design. The runtime described here is -implemented behind a hidden, pipeline-internal CLI surface, but it is not yet -wired into generated pipelines: `ado-aw catalog --kind ado-proxy` -still reports `runtime_available: false` until the compiler, credential, and -AWF wiring land._ +_Security contract and implementation design. The runtime is wired into +generated pipelines when `tools.azure-devops` is enabled. The catalog still +reports `runtime_available: false` until the final author-facing availability +flip lands._ ## Why this is required @@ -15,18 +14,26 @@ inherits that identity's Azure DevOps permissions. The compiler therefore cannot safely treat the service-connection name or ARM scope as an authorization boundary. -The current implementation keeps `SC_READ_TOKEN` out of the Agent process and -passes it only to the trusted first-party Azure DevOps MCP backend. Direct -`az devops`, curl, and SDK calls are not authenticated. Issues -[#1652](https://github.com/githubnext/ado-aw/issues/1652) and -[#1717](https://github.com/githubnext/ado-aw/issues/1717) track the missing -credential-isolated direct HTTP path and the earlier documentation mismatch. +The implementation keeps `SC_READ_TOKEN` out of the Agent, MCPG, Azure DevOps +MCP container, and Azure CLI. Only `ado-proxy` holds it. The MCP is redirected +at the proxy with `--add-host`; a generated `az` wrapper sets `HTTPS_PROXY`, +process-scoped CA trust, and a non-secret sentinel PAT. ## Scope -The first production provider supports Azure DevOps Services reads for the -current organization, project, and repository. Broader scopes require explicit -configuration. The following remain outside this provider: +The provider supports Azure DevOps Services reads for: + +- the current organization/project/repository (by name or GUID); +- Azure Repos `type: git` resources declared under `repos:`, repository-only; +- additional organizations/projects/repositories declared under + `permissions.read.allow`, resolved organization-relatively. + +Capabilities are global across those scopes and may be narrowed under +`permissions.read.capabilities`; discovery is always enabled. Cross-org works +only where the one service-connection identity has access in the same AAD +tenant. Cross-tenant reads need another credential and are unsupported. + +The following remain outside this provider: - Azure Resource Manager, Microsoft Graph, and Azure data-plane APIs; - Stage 1 mutations; @@ -256,61 +263,38 @@ verify the first certificate`. ### Credential delivery -Acquisition already exists: `generate_acquire_ado_token` emits an `AzureCLI@2` +`generate_acquire_ado_token` emits an `AzureCLI@2` step that mints an ADO-audience token from the ARM service connection and stores it as the secret pipeline variable `SC_READ_TOKEN`. -**Delivery must not use a runner path.** The engine reads its bearer from -`--token-file`, and the obvious choice — a file under the runner's `/tmp` — -is unsafe for the same reason the CA private key was: AWF mounts `/tmp` into the -agent at both `/tmp` and `/host/tmp` (`agent-service.ts`), which is exactly how -AWF installs its own `gh` wrapper. A token written there is agent-readable, and -the boundary is gone. - -Two mechanisms avoid a shared path, both to be settled by -`proxy-token-delivery`: - -- **stdin**, alongside the CA material — simplest, but one-shot, so it cannot - rotate; -- **`docker cp` into the running container**, or a named volume mounted only - into the engine — supports rotation, at the cost of a refresh loop running - during the agent step. - -An ADO access token is typically valid ~1 hour, so a single token covers most -runs; rotation matters for long ones. WIF assertions are much shorter -(~5–10 min), but they are consumed at mint time and never reach the engine. - -**The MCP must stop receiving the real token.** Today the compiler passes -`-e ADO_MCP_AUTH_TOKEN="$SC_READ_TOKEN"` straight into the MCP container. Under -interception the engine holds the credential and injects it after an allow -decision, so the MCP must be given a non-secret sentinel instead. Leaving the -real token in its environment would make the proxy decorative on that path: the -MCP could still authenticate directly if it ever reached Azure DevOps another -way. +**Delivery never uses a runner path.** AWF mounts the runner's `/tmp` into the +agent at both `/tmp` and `/host/tmp`; a bearer written there is agent-readable +and destroys the boundary. Instead, the host step builds one versioned JSON +document containing base64 certificate material and the bearer and pipes it to +`docker run -i`. The engine reads it once from stdin and holds private material +in memory. The CA signing key and leaf keys are shredded immediately after +handover; only the public interception certificate is published. + +The MCP and `az` wrapper receive a non-secret sentinel. The proxy strips all +client credential headers and attaches its bearer only after a complete allow +decision. The token is not exposed in container `Env`, argv, the process table, +or an agent-readable mount. + +The token is not rotated. Proxied workflows are therefore bounded at compile +time to 50 minutes so the run cannot silently outlive its credential. ### Credential renewal -Production must support WIF renewal beyond the original assertion lifetime. -The expected trusted path requests a fresh assertion from -`$(System.OidcRequestUri)` using a host-task-only `$(System.AccessToken)`, -re-authenticates outside AWF, and atomically updates a proxy-only token file. -If this cannot be demonstrated without exposing identity material, rollout -stops rather than falling back to an Agent credential. - -This is an Azure Pipelines-supported pattern rather than a custom refresh -protocol. `AzureCLI@2` implements the same behavior behind its experimental -`keepAzSessionActive` input: for WIF connections it requests a new OIDC token -and repeats `az login --federated-token` on an interval. The proxy integration -must use `addSpnToEnvironment: false`; client ID, tenant ID, and service -connection ID are non-secret task metadata, while the raw OIDC assertion and -`System.AccessToken` remain trusted-task-only. +Renewal is deferred. Extending the 50-minute limit requires a trusted refresh +path that never exposes a WIF assertion, `System.AccessToken`, or refreshed ADO +bearer to the agent. Rollout must not fall back to an agent credential. ## Authorization contract The operation catalog matches normalized host, method, route template, API -version, current organization/project/repository scope, and bounded -operation-specific request fields. It explicitly models read-like POSTs; -method alone never determines safety. +version, organization-relative project/repository scope, and bounded +operation-specific request fields. Every catalogued operation is `GET` or +`OPTIONS`; all other methods are rejected before route matching. The in-tree catalog can be inspected before runtime enablement with `ado-aw catalog --kind ado-proxy --json`. Its @@ -332,9 +316,10 @@ classification or exfiltration-prevention system. The proxy ships as **`ado-proxy`**, a TypeScript bundle in `scripts/ado-script/`, packaged in `ado-script.zip` alongside the other -`ado-script` bundles and already covered by the `supply-chain:` mirror. AWF -runs it as the managed sidecar's entrypoint from the pinned AWF agent image, -the same entrypoint-override pattern SafeOutputs already uses. +`ado-script` bundles and already covered by the `supply-chain:` mirror. A host +step bind-mounts the bundle into the existing `node:20-slim` image and starts +it before AWF. AWF's repeatable `--topology-attach` then dual-homes that +container onto `awf-net`; no new image is built, published, pinned, or mirrored. It is not a Rust subcommand. A Rust implementation would need a TLS stack plus certificate minting (`rustls` + `rcgen` → `ring`), which would make a native C @@ -343,23 +328,22 @@ pure-Rust and must stay buildable without one. Node's built-in `tls`, `http`, and `net` modules cover the same ground with no new runtime dependency, and match how AWF implements its own credential-isolating sidecars. -Configuration follows the generic `AWF_POLICY_PROXY_*` contract AWF publishes -for any policy-proxy sidecar. No credential is ever passed through argv or the -environment: the bearer is read from a private, rotating token file, and the -policy document is a mounted read-only JSON file carrying the -`catalog_version` the bundle re-checks at startup, so a stale policy fails -closed. +Configuration is supplied by compiler-owned flags plus a mounted, read-only +policy JSON document. No credential is passed through argv, environment, or a +runner file: the bearer arrives in the versioned stdin material document. The +policy carries the `catalog_version` the bundle re-checks at startup, so a +stale compiler/bundle pair fails closed. Request handling has exactly two paths: -- **Direct TLS on the protected path.** Both the broker (`az`) and the - DNS-aliased MCP connect straight to the engine on 443. It terminates TLS with - a leaf selected by SNI (ALPN pinned to `http/1.1`), normalizes the request, +- **Direct TLS for the DNS-redirected MCP.** The MCP connects to + `dev.azure.com:443`, which `--add-host` redirects to the engine. It terminates + TLS with a leaf selected by SNI (ALPN pinned to `http/1.1`), normalizes the request, evaluates it against the versioned catalog, drops every client credential and forwarding header, and — only after a complete allow decision, and only for a protected upstream — attaches the current bearer and forwards through Squid. -- **`CONNECT` for proxy-style clients.** Retained for clients configured with - `HTTPS_PROXY`. Protected destinations are intercepted as above; non-protected +- **`CONNECT` for `az`.** The generated wrapper sets `HTTPS_PROXY` to the + engine's port 11080. Protected destinations are intercepted as above; non-protected destinations are byte-tunnelled to Squid untouched, so package feeds behave exactly as they do without the sidecar. Plain HTTP to a protected host, and `CONNECT` to a protected host on any port other than 443, are denied. diff --git a/docs/ado-script.md b/docs/ado-script.md index 576170be2..2f23aff2c 100644 --- a/docs/ado-script.md +++ b/docs/ado-script.md @@ -441,8 +441,10 @@ the codegen output. ## `ado-proxy`: the same contract, applied to policy -`ado-proxy.js` is the credential-isolated Azure DevOps policy proxy AWF runs as -a managed sidecar (see [`ado-proxy-design.md`](ado-proxy-design.md)). It is the +`ado-proxy.js` is the credential-isolated Azure DevOps policy proxy. A host +step mounts it into `node:20-slim`, and AWF attaches that externally launched +container to its internal network (see +[`ado-proxy-design.md`](ado-proxy-design.md)). It is the one bundle that is a **long-running server** rather than a single-shot step: it starts before the agent and is torn down when the agent exits. @@ -474,16 +476,17 @@ each boundary can be tested in isolation: | Module | Responsibility | |---|---| -| `config.ts` | Parse argv / the generic `AWF_POLICY_PROXY_*` env contract and the mounted policy document. Fail-closed on anything unrecognized. | +| `config.ts` | Parse argv / env and the mounted policy document. Fail-closed on anything unrecognized, including nested scope keys. | | `catalog.ts` | Load the generated snapshot; canonicalize hosts (case, `host:port`, trailing DNS dot) for the protected-set check. | | `route.ts` | Normalize the request target and match catalog route templates. Refuses ambiguous encodings rather than rewriting them. | | `api-version.ts` | Resolve the API version from both the query string and the `Accept` header, and reject disagreement or an out-of-window value. | +| `scope.ts` | Build the organization-relative current/additional scope index once at startup, preserving repository-only grants. | | `policy.ts` | The allow/deny decision: method, denied family, route, capability, version, query, and scope. | | `headers.ts` | Allow-list request and response headers; strip every client credential. | -| `token.ts` | Read the rotating bearer file, cached on mtime and size. | -| `ca.ts` | Mint the ephemeral CA and per-host leaves via `openssl`; publish only the public PEM. | +| `token.ts` | Hold the one-shot stdin bearer in memory. | +| `ca.ts` | Parse the versioned stdin material document (CA, per-host leaves, bearer) and publish only the public PEM. | | `upstream.ts` | CONNECT through Squid — the sidecar's only route out. | -| `response.ts` | Bound and filter response bodies; validate organization-addressed reads against the pinned scope. | +| `response.ts` | Bound and filter response bodies; validate organization-addressed reads against the organization-relative scope index. | | `log.ts` | The schema-versioned, sanitized JSONL decision stream. | | `server.ts` | Wire the two request paths together. | diff --git a/docs/cli.md b/docs/cli.md index 365ba62f7..12238062d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -195,7 +195,8 @@ These commands are started by the pipeline itself (or by AWF on its behalf) and > The credential-isolated Azure DevOps policy proxy is **not** an `ado-aw` > subcommand. It ships as the `ado-proxy` TypeScript bundle in > `scripts/ado-script/` (packaged in `ado-script.zip`, mirrored by -> `supply-chain:`), and AWF runs it as the managed sidecar's entrypoint. See +> `supply-chain:`). The pipeline starts it in `node:20-slim`, then AWF attaches +> that trusted container to its isolated network. See > [`docs/ado-proxy-design.md`](ado-proxy-design.md) for its configuration > contract, and `ado-aw catalog --kind ado-proxy` for the versioned operation > catalog it enforces. diff --git a/docs/front-matter.md b/docs/front-matter.md index e607edc7d..0abc3adce 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -218,7 +218,16 @@ network: # optional network policy (standalone target only # variable-groups: # optional: import ADO Library variable groups (standalone/1es only) # - My Variable Group # each entry must be the exact ADO Library group name (see "Variable Groups" section) permissions: # optional ADO access token configuration (see docs/network.md#permissions-ado-access-tokens) - read: my-read-arm-connection # ARM SC for Stage 1 trusted ADO MCP auth; raw token is not in Agent env + read: my-read-arm-connection # shorthand: proxy gets the ARM SC token; Agent/MCP/az get no real token + # read: # object form: narrow capabilities / add cross-org or project scope + # service-connection: my-read-arm-connection + # capabilities: [core, repos] # discovery is always enabled + # allow: # additive to current org/project/repo and type: git repos: + # - organization: partner-org # same AAD tenant; cross-tenant needs another credential + # projects: + # - project: Shared + # project-id: 33333333-3333-3333-3333-333333333333 # optional GUID-form calls + # repositories: [shared-api] # empty/omitted => project reads only write: my-write-arm-connection # OPTIONAL ARM SC for Stage 3 executor writes. # Default: executor uses $(System.AccessToken). # Set this only for cross-org writes or diff --git a/docs/mcp.md b/docs/mcp.md index bbdce674d..f6f4336bb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -70,11 +70,11 @@ env: STATIC_CONFIG: "some-value" # Literal value embedded in config ``` -For the first-party `tools.azure-devops` integration, the compiler maps -`SC_READ_TOKEN` to `ADO_MCP_AUTH_TOKEN` on MCPG, and MCPG passes that value to -the trusted ADO MCP child. This automatic mapping does not apply to arbitrary -user-defined `mcp-servers:` entries and does not populate -`AZURE_DEVOPS_EXT_PAT` in the Agent sandbox. +The first-party `tools.azure-devops` integration is deliberately different: +it gives the MCP a non-secret sentinel in `ADO_MCP_AUTH_TOKEN`. The real +`SC_READ_TOKEN` is delivered only to `ado-proxy` over stdin and is injected +into an upstream request only after policy allows it. This behavior does not +apply to arbitrary user-defined `mcp-servers:` entries. ## Example: Azure DevOps MCP with Authentication diff --git a/docs/network.md b/docs/network.md index 2b94d6cc0..39192270a 100644 --- a/docs/network.md +++ b/docs/network.md @@ -92,10 +92,11 @@ When (and only when) `AW_AZ_MOUNTS` is non-empty, a follow-up *Append Azure CLI prompt* step appends an Azure CLI advisory section to `/tmp/awf-tools/agent-prompt.md`. The agent reads the prompt on startup and learns that `az` is on PATH, what it's good for -(`az devops` and Azure commands are not pre-authenticated), the authenticated -ADO MCP alternative, and the fallback path (`missing-tool` safe output naming -`azure-cli`). The advisory tells the agent not to sign in or place Azure -credentials in the sandbox. +(catalogued Azure DevOps reads through `az devops`, `az repos`, `az pipelines`, +`az boards`, and `az rest`), what is deliberately unavailable (writes, secrets, +ARM, and Graph), and the fallback path (`missing-tool` safe output naming +`azure-cli`). The advisory tells the agent not to sign in: the wrapper carries +only a sentinel and the proxy owns the real credential. The step is gated by `condition: ne(variables['AW_AZ_MOUNTS'], '')`, which reuses the same pipeline variable the detection step writes. @@ -225,7 +226,7 @@ Operators can scope further per-pipeline by editing the build definition's ```yaml permissions: - read: my-read-arm-connection # Stage 1 trusted ADO MCP credential + read: my-read-arm-connection # trusted ado-proxy token source # write: my-write-arm-connection # Optional — see below ``` @@ -246,13 +247,19 @@ agents. Set `permissions.write` only when you need: ### Security Model - **`permissions.read`**: Mints an ADO-audience token for the trusted - first-party Azure DevOps MCP backend when `tools.azure-devops` is enabled. - The raw token is not injected into the Agent process or direct Azure CLI. - Azure DevOps permissions on the underlying identity remain the authorization - boundary until the policy proxy described in - [`ado-proxy-design.md`](ado-proxy-design.md) is implemented. + `ado-proxy` process when `tools.azure-devops` is enabled. The raw token is + not injected into the Agent, Azure CLI, MCPG, or Azure DevOps MCP container. + The proxy attaches it only after a deny-by-default catalog and scope check. - An **object form** of `permissions.read` is reserved for that proxy: + The scalar form enables all read capabilities for the implicit current + organization/project/repository scope: + + ```yaml + permissions: + read: my-read-sc + ``` + + The **object form** narrows capabilities and adds explicit scopes: ```yaml permissions: @@ -263,14 +270,25 @@ agents. Set `permissions.write` only when you need: - organization: other-org projects: - project: Other Project + project-id: 33333333-3333-3333-3333-333333333333 # optional repositories: [other-repo] # omit for project-scoped reads only ``` - It **fails compilation today**, deliberately: accepting it while the proxy - is unwired would silently ignore every restriction it declares, which is - strictly worse than rejecting it. An organization entry with no `projects` - is also rejected, because granting an entire organization by *omitting* a - key is the class of accident this proxy exists to prevent. + `allow:` is additive to the current scope. It is organization-relative: a + project granted in one organization does not match the same project name in + another. `project-id` is optional; without it, name-form calls work and a + client using a cached GUID fails closed. + + Azure Repos `type: git` entries under `repos:` also grant API reads for that + repository, including `checkout: false`. This is repository-only: declaring + `Project/repo` does **not** grant work-item, build, or pipeline reads for + `Project`. Non-ADO repository types grant nothing. + + Cross-organization reads use the same ADO-audience token and therefore work + only where the service-connection identity has access in the same AAD + tenant. Cross-tenant reads require another credential and are unsupported. + An organization entry with no `projects` is rejected because omission must + never grant an entire organization. - **`permissions.write` (optional)**: Mints a write-capable ADO-scoped token used **only** by the executor in Stage 3 (`SafeOutputs` job). Overrides the default `$(System.AccessToken)` for write operations. Never exposed diff --git a/docs/tools.md b/docs/tools.md index 808185449..8f995e2dc 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -64,7 +64,8 @@ During Stage 3 execution, memory files are validated (path safety, extension fil ### Azure DevOps MCP (`azure-devops:`) -First-class Azure DevOps MCP integration. Auto-configures the ADO MCP container, token mapping, MCPG entry, and network allowlist. +First-class Azure DevOps MCP integration. Auto-configures the ADO MCP +container, credential-isolated policy proxy, and MCPG entry. ```yaml # Simple enablement (auto-infers org from git remote) @@ -80,15 +81,22 @@ tools: ``` When enabled, the compiler: -- Generates a containerized stdio MCP entry (`node:20-slim` + `npx @azure-devops/mcp`) in the MCPG config -- Injects `ADO_MCP_AUTH_TOKEN` (sourced from `SC_READ_TOKEN`) into the MCP container when `permissions.read` is configured — this authenticates the ADO MCP -- Adds ADO-specific hosts to the network allowlist +- Requires `permissions.read` as the trusted proxy's token source +- Installs the pinned `@azure-devops/mcp` package on the runner and mounts it + read-only into an unchanged `node:20-slim` container; the isolated container + needs no npm registry access +- Runs that container on an internal network with `dev.azure.com` redirected + to `ado-proxy` and a public interception CA trusted only by that process +- Gives the MCP a non-secret sentinel in `ADO_MCP_AUTH_TOKEN`; the real token + exists only in `ado-proxy`, which strips client credentials and attaches its + bearer after an allow decision - Auto-infers org from the git remote URL at compile time (overridable via `org:` field) - Fails compilation if org cannot be determined (no explicit override and no ADO git remote) -> **Note:** the first-party MCP uses `ADO_MCP_AUTH_TOKEN`. The compiler does -> not inject `AZURE_DEVOPS_EXT_PAT` or another Azure credential into the Agent -> sandbox for direct CLI use. +The generated `az` wrapper similarly carries only a sentinel PAT and routes +Azure DevOps traffic through the proxy. Catalogued reads (`az devops`, +`az repos`, `az pipelines`, `az boards`, and `az rest`) work without signing +in; writes and secret-bearing route families fail closed. ## Built-in CLIs diff --git a/src/compile/types.rs b/src/compile/types.rs index 7ab4f01d5..2f3079001 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -1859,8 +1859,7 @@ pub struct PermissionsConfig { /// /// The scalar form remains shorthand for a service connection with the /// compiler-owned current-organization/project/repository policy. The object -/// form prepares explicit policy configuration for the credential-isolated -/// proxy and is rejected by compilation until that runtime is wired. +/// form configures the credential-isolated proxy's capability and scope tree. #[derive(Debug, Deserialize, Clone, PartialEq)] #[serde(untagged)] pub enum ReadPermissionConfig { From c90e7a98c1902cbf79116c046e47854ce5a1b900 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 4 Aug 2026 23:45:09 +0100 Subject: [PATCH 29/42] feat(ado-proxy): enable the scoped proxy runtime Flips the author-facing catalog availability bit after the complete path is wired and proven: compiler-emitted policy, stdin-only credential custody, host-started proxy container, repeatable AWF topology attachment, internal MCP network, sentinel MCP and az clients, capability narrowing, current-scope identifiers, explicit cross-organization scopes and implicit repository-only grants. Regenerates the committed TypeScript catalog snapshot and converts the old "must remain disabled" tests into regression guards for availability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 12 +++++------- .../src/ado-proxy/catalog-drift.test.ts | 9 +++++---- .../ado-script/src/ado-proxy/catalog.gen.json | 2 +- src/ado_proxy/catalog.rs | 19 +++++++++++-------- src/inspect/catalog.rs | 4 ++-- 5 files changed, 24 insertions(+), 22 deletions(-) diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index f8fd1ea51..f8b5f5985 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -1,9 +1,8 @@ # Credential-Isolated Azure DevOps Proxy (`ado-proxy`) _Security contract and implementation design. The runtime is wired into -generated pipelines when `tools.azure-devops` is enabled. The catalog still -reports `runtime_available: false` until the final author-facing availability -flip lands._ +generated pipelines when `tools.azure-devops` is enabled, and the catalog +reports `runtime_available: true`._ ## Why this is required @@ -296,10 +295,9 @@ version, organization-relative project/repository scope, and bounded operation-specific request fields. Every catalogued operation is `GET` or `OPTIONS`; all other methods are rejected before route matching. -The in-tree catalog can be inspected before runtime enablement with -`ado-aw catalog --kind ado-proxy --json`. Its -`runtime_available` field remains `false` until the credential and AWF wiring -are enabled. +The in-tree catalog can be inspected with +`ado-aw catalog --kind ado-proxy --json`. Its `runtime_available` field is +`true`; the credential, topology, client and scope wiring are enabled. Unknown hosts, methods, routes, API versions, redirects, or body shapes fail closed. Client authorization is never preferred over the proxy credential. diff --git a/scripts/ado-script/src/ado-proxy/catalog-drift.test.ts b/scripts/ado-script/src/ado-proxy/catalog-drift.test.ts index b2d7da3f5..bdaf543c4 100644 --- a/scripts/ado-script/src/ado-proxy/catalog-drift.test.ts +++ b/scripts/ado-script/src/ado-proxy/catalog-drift.test.ts @@ -91,10 +91,11 @@ describe("ado-proxy catalog drift guard", () => { expect(catalog.schema_version).toBe("ado-aw/ado-proxy-catalog/v1"); }); - it("keeps the runtime unreachable until the compiler wiring lands", () => { - // The bundle exists and is tested, but nothing emits its sidecar or policy - // document yet, so authors must not be told the capability is available. - expect(readSnapshot().runtime_available).toBe(false); + it("reports the runtime available after the full wiring lands", () => { + // Author-facing availability is the final gate: policy emission, stdin + // credential custody, topology attachment, sentinel clients and scoped + // authorization are all wired and covered by integration tests. + expect(readSnapshot().runtime_available).toBe(true); }); it("protects only Azure DevOps REST hosts", () => { diff --git a/scripts/ado-script/src/ado-proxy/catalog.gen.json b/scripts/ado-script/src/ado-proxy/catalog.gen.json index ce3ef1641..b5112d9d6 100644 --- a/scripts/ado-script/src/ado-proxy/catalog.gen.json +++ b/scripts/ado-script/src/ado-proxy/catalog.gen.json @@ -1,6 +1,6 @@ { "schema_version": "ado-aw/ado-proxy-catalog/v1", - "runtime_available": false, + "runtime_available": true, "protected_hosts": [ "dev.azure.com", "app.vssps.visualstudio.com" diff --git a/src/ado_proxy/catalog.rs b/src/ado_proxy/catalog.rs index 0bc23f5d0..4d47a065a 100644 --- a/src/ado_proxy/catalog.rs +++ b/src/ado_proxy/catalog.rs @@ -7,12 +7,12 @@ pub const CATALOG_SCHEMA_VERSION: &str = "ado-aw/ado-proxy-catalog/v1"; /// Whether authors can actually reach this catalog through a compiled pipeline. /// -/// This gates the *author-facing* path, not the existence of the runtime: the -/// `ado-proxy` bundle is implemented and tested, but nothing emits its sidecar, -/// policy document, or credential lifecycle yet. It flips only once -/// `compiler-proxy-wiring` lands against a pinned AWF release whose agent image -/// supports the managed policy proxy and CA. -pub const RUNTIME_AVAILABLE: bool = false; +/// This gates the *author-facing* catalog response. It is true only after the +/// complete path is wired and proven: compiler-emitted policy, stdin-only +/// credential custody, host-started proxy container, repeatable AWF topology +/// attachment, internal MCP network, sentinel clients, generated `az` wrapper, +/// capability narrowing, and organization-relative scope enforcement. +pub const RUNTIME_AVAILABLE: bool = true; /// Canonical Azure DevOps Services REST host for the current organization. pub const ORGANIZATION_HOST: &str = "dev.azure.com"; @@ -681,10 +681,13 @@ mod tests { use std::collections::HashSet; #[test] - fn catalog_is_versioned_and_runtime_stays_disabled() { + fn catalog_is_versioned_and_runtime_is_available() { let catalog = catalog(); assert_eq!(catalog.schema_version, CATALOG_SCHEMA_VERSION); - assert!(!catalog.runtime_available); + assert!( + catalog.runtime_available, + "the compiler, topology, credential and scope wiring are complete" + ); assert!(!catalog.operations.is_empty()); } diff --git a/src/inspect/catalog.rs b/src/inspect/catalog.rs index 2f325762a..83cf8495e 100644 --- a/src/inspect/catalog.rs +++ b/src/inspect/catalog.rs @@ -428,14 +428,14 @@ mod tests { } #[test] - fn ado_proxy_catalog_reports_policy_only_runtime() { + fn ado_proxy_catalog_reports_available_runtime() { let catalog = catalog_kind("ado-proxy").unwrap(); let proxy = catalog.ado_proxy.unwrap(); assert_eq!( proxy.schema_version, crate::ado_proxy::catalog::CATALOG_SCHEMA_VERSION ); - assert!(!proxy.runtime_available); + assert!(proxy.runtime_available); assert!(!proxy.operations.is_empty()); assert!(catalog.safe_outputs.is_empty()); } From c527ff2bfb822d9d8ce59adf609fcaf3213d8bfb Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 5 Aug 2026 07:20:34 +0100 Subject: [PATCH 30/42] docs(ado-proxy): finalize shipped behavior and contributor guidance Updates the top-level README and contributor instructions to reflect the shipped credential path: only ado-proxy holds SC_READ_TOKEN, while the Agent, MCPG, Azure DevOps MCP and wrapped az receive no real credential. Records the AWF chroot trap in AGENTS.md: runner /tmp is mounted into the agent at both /tmp and /host/tmp, so host steps must never stage credentials or private keys there and assume deletion makes the exchange safe. Private material must use stdin or a container-private volume; only intentionally public files may be published under /tmp. Also refreshes architecture entries and removes the last stale trusted-MCP and rotating-token-file wording. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- AGENTS.md | 22 +++++++++++++++++----- README.md | 20 +++++++++++--------- docs/ado-proxy-design.md | 5 +++-- docs/network.md | 2 +- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 247b6abb6..d65d09f7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,9 +31,10 @@ repository. The pipeline yaml references the agent. Every compiled pipeline runs as three sequential jobs: 1. **Agent (Stage 1)** — runs the AI agent inside an AWF network-isolated - sandbox. When configured, a trusted Azure DevOps MCP backend holds the - Stage 1 ADO credential; the raw token is not injected into the Agent - process. The agent produces *safe-output proposals* (e.g. "create this PR", + sandbox. When configured, a trusted `ado-proxy` process holds the Stage 1 + ADO credential and injects it only after a scoped read-policy decision; the + raw token is not injected into the Agent, MCPG, MCP container, or wrapped + `az`. The agent produces *safe-output proposals* (e.g. "create this PR", "comment on this work item") rather than acting directly. 2. **Detection (Stage 2)** — by default, a separate agent inspects Stage 1's proposals for prompt injection, secret leaks, and other threats. Authors can @@ -142,7 +143,8 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ └── discovery.rs # Project-scope pipeline discovery (`--all-repos` / `--source` flags) │ ├── ado_proxy/ # Authoritative Stage 1 ADO proxy policy (runtime ships as the `ado-proxy` ado-script bundle; see docs/ado-proxy-design.md) │ │ ├── mod.rs # Module entry; why the runtime is TypeScript; the compiler/sidecar anti-divergence contract -│ │ └── catalog.rs # Versioned deny-by-default read-operation catalog (surfaced by `ado-aw catalog --kind ado-proxy`; exported to the bundle as schema + committed snapshot) +│ │ ├── catalog.rs # Versioned deny-by-default read-operation catalog (surfaced by `ado-aw catalog --kind ado-proxy`; exported to the bundle as schema + committed snapshot) +│ │ └── policy.rs # Compiler-owned policy document: runtime scope placeholders, capability lowering, explicit cross-org/project scopes, and implicit repository-only grants from type: git repos: │ ├── audit/ # `ado-aw audit` command — downloads pipeline artifacts and runs analyzers │ │ ├── mod.rs # Module entry; declares submodules; re-exports `model::*` and CLI helpers │ │ ├── cli.rs # CLI entry point for the `audit` subcommand @@ -278,7 +280,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ ├── executor-e2e/ # Stage 3 safe-output E2E test harness (not a bundle; runs deterministic scenarios against a real ADO project and files a GitHub issue on failure) │ ├── compiler-smoke-e2e/ # Deterministic compiler-candidate smoke E2E orchestrator (not a bundle): stages a compiler candidate, pushes to a short-lived `ado-aw-mirror` branch, queues the four FIXED "candidate lane" pipeline definitions, and asserts they go green. Consumes fixtures from `tests/compiler-smoke-e2e/`; built to `test-bin/` by `build:compiler-smoke-e2e`, listed in `NON_BUNDLE_DIRS`. │ ├── prepare-pr-base/ # create-pull-request preparer (bundled to prepare-pr-base.js): Agent mode uses ADO diff metadata + bounded dual-ref fallback to make the merge-base reachable; SafeOutputs mode fetches only the target worktree tip -│ ├── ado-proxy/ # Credential-isolated ADO policy proxy (bundled to ado-proxy.js). The one long-running bundle: AWF runs it as a managed sidecar. `catalog.gen.json` + `../shared/ado-proxy-catalog.types.gen.ts` are generated from Rust by `export-ado-proxy-catalog{,-schema}` and drift-guarded; a `catalog_version` mismatch fails closed at startup. +│ ├── ado-proxy/ # Credential-isolated ADO policy proxy (bundled to ado-proxy.js). The pipeline mounts it into node:20-slim and starts it before AWF; AWF attaches the trusted container via --topology-attach. scope.ts builds the organization-relative current/additional scope index; catalog.gen.json + ../shared/ado-proxy-catalog.types.gen.ts are generated from Rust by export-ado-proxy-catalog{,-schema} and drift-guarded; a catalog_version mismatch fails closed at startup. │ ├── trigger-e2e/ # Test-only gate-spec / trigger-evaluation harness (not a bundle): mirrors Rust `Fact::ALL` in `gate-spec.ts`; `fact-catalog.gen.json` is generated by `export-fact-catalog` and drift-guarded by CI │ └── shared/ # Shared modules across bundles (auth, ado-client, env-facts, types.gen.ts) ├── tests/ # Integration tests and fixtures @@ -467,6 +469,16 @@ Following the gh-aw security model: 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. + **Contributor warning — AWF's chroot makes runner `/tmp` agent-readable.** + The agent's root is the host's `/host` bind mount, and AWF mounts the same + runner `/tmp` at both `/tmp` and `/host/tmp` (`agent-service.ts`). Therefore + anything a host pipeline step writes under runner `/tmp` is visible inside + the agent sandbox. Never stage bearer tokens, CA private keys, WIF + assertions, service-connection material, or other credentials there and + assume deletion will make the exchange safe. This trap has caused repeated + incorrect designs in credential-bearing work. Stream private material over + stdin or use a container-private volume; publish only intentionally public + files (for example the interception CA certificate) under `/tmp`. 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/README.md b/README.md index a5aea63fa..4bcbe0b19 100644 --- a/README.md +++ b/README.md @@ -185,16 +185,18 @@ underlying identities the minimum Azure DevOps permissions. | | Read Connection | Write Connection | |---|---|---| -| **Used by** | Stage 1 trusted ADO MCP backend | Stage 3 safe outputs executor | -| **Purpose** | Query ADO APIs through configured MCP tools | Create PRs, work items, link artifacts | -| **Exposed to agent?** | Raw token: no; MCP tools: yes | No | +| **Used by** | Trusted Stage 1 `ado-proxy` process | Stage 3 safe outputs executor | +| **Purpose** | Authenticate catalogued ADO reads from MCP tools and wrapped `az` | Create PRs, work items, link artifacts | +| **Exposed to agent?** | Raw token: no; scoped read tools: yes | No | | **Token variable** | `SC_READ_TOKEN` | `SC_WRITE_TOKEN` | | **Front matter field** | `permissions.read` | `permissions.write` | -The raw Stage 1 token is passed to the trusted Azure DevOps MCP backend, not to -the Agent process or direct `az devops` commands. The current MCP backend still -relies on the identity's Azure DevOps permissions, so operators must configure -that identity as least-privileged. Write actions belong in Stage 3 +The raw Stage 1 token is delivered only to `ado-proxy` over stdin — never to +the Agent, MCPG, Azure DevOps MCP container, or wrapped `az`. The proxy +enforces a deny-by-default read catalog and organization-relative scope tree +before attaching the bearer. Operators must still configure the identity as +least-privileged: the proxy constrains the agent path, while Azure DevOps +remains the upstream authorization boundary. Writes belong in Stage 3 (`SafeOutputs`) after threat analysis. #### Creating the Service Connections @@ -207,7 +209,7 @@ that identity as least-privileged. Write actions belong in Stage 3 **Read connection** (e.g., `ado-agent-read`): - Scope: subscription or resource group level - Used by: the Agent job to mint an ADO-audience token for the trusted - Azure DevOps MCP backend (`499b84ac-1321-427f-aa17-267ca6975798`) + `ado-proxy` process (`499b84ac-1321-427f-aa17-267ca6975798`) - Required ADO setup: grant the underlying identity only the Azure DevOps read permissions the workflow needs; the ARM scope does not enforce this @@ -234,7 +236,7 @@ that identity as least-privileged. Write actions belong in Stage 3 #### Permission Combinations -| Configuration | Trusted ADO MCP can authenticate? | Safe outputs can write? | +| Configuration | Scoped Stage 1 ADO reads work? | Safe outputs can write? | |---|---|---| | Both `read` + `write` | Yes, when `tools.azure-devops` is enabled | Yes (via ARM-minted token) | | Only `read` | Yes, when `tools.azure-devops` is enabled | Yes (via `$(System.AccessToken)`) | diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index f8b5f5985..ec38e2721 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -54,9 +54,10 @@ client-provided HTTP headers and bodies are untrusted. The protected credential set is: - the identity behind `permissions.read`; -- workload-identity assertions and any `System.AccessToken` used to renew them; +- workload-identity assertions and the host-task `System.AccessToken` used to + mint the one-shot ADO token; - every Azure DevOps REST bearer minted from that identity; -- private token files and proxy CA private keys. +- proxy CA and leaf private keys. Those values must never appear in Agent or Detection environment, argv, `/proc`, files, mounts, prompts, MCP configuration or payloads, logs, or diff --git a/docs/network.md b/docs/network.md index 39192270a..fdd4a80b2 100644 --- a/docs/network.md +++ b/docs/network.md @@ -300,7 +300,7 @@ agents. Set `permissions.write` only when you need: ### Examples ```yaml -# Trusted ADO MCP can authenticate; executor writes via $(System.AccessToken). +# Scoped MCP and wrapped-az reads work through ado-proxy; executor writes via $(System.AccessToken). permissions: read: my-read-sc From b548b79a8c27bc62e9a7517aa1a4f351e0e009ef Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 5 Aug 2026 12:15:37 +0100 Subject: [PATCH 31/42] test(ado-proxy): add candidate runner smoke coverage Adds a candidate-only case to the merged smoke-lane framework. On a real ADO runner it must complete wrapped-az reads of the current project by name and GUID, a current-repository read by GUID, and a first-party Azure DevOps MCP read before emitting its proof build tag. Extends case assertions with whole-pipeline required/forbidden snippets so the orchestrator fails before queueing if compiled YAML loses the second AWF topology attachment, the sentinel MCP token, the internal MCP network, or the proxy lifecycle steps, or regresses to direct SC_READ_TOKEN mapping or host networking. The smoke build chain now rebuilds ado-proxy alongside every other shipping bundle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- scripts/ado-script/package.json | 2 +- .../__tests__/assertions.test.ts | 28 +++++++ .../__tests__/cases.test.ts | 26 ++++++- .../__tests__/index.test.ts | 27 ++++++- .../src/compiler-smoke-e2e/assertions.ts | 19 +++++ .../src/compiler-smoke-e2e/cases.ts | 35 ++++++++- .../src/compiler-smoke-e2e/index.ts | 5 ++ tests/smoke/README.md | 10 ++- tests/smoke/ado-proxy.md | 77 +++++++++++++++++++ tests/smoke/cases.json | 29 +++++++ 10 files changed, 246 insertions(+), 12 deletions(-) create mode 100644 tests/smoke/ado-proxy.md diff --git a/scripts/ado-script/package.json b/scripts/ado-script/package.json index d558cb907..1261d0ef2 100644 --- a/scripts/ado-script/package.json +++ b/scripts/ado-script/package.json @@ -31,7 +31,7 @@ "build:check": "ls -lh gate.js && wc -c gate.js", "codegen": "node -e \"require('node:fs').mkdirSync('schema', { recursive: true })\" && cargo run --quiet --manifest-path ../../Cargo.toml -- export-gate-schema --output schema/gate-spec.schema.json && npx json2ts schema/gate-spec.schema.json -o src/shared/types.gen.ts --bannerComment \"// AUTO-GENERATED from Rust IR via cargo run -- export-gate-schema. Do not edit; run npm run codegen.\" && cargo run --quiet --manifest-path ../../Cargo.toml -- export-fact-catalog --output src/trigger-e2e/fact-catalog.gen.json && cargo run --quiet --manifest-path ../../Cargo.toml -- export-ado-proxy-catalog-schema --output schema/ado-proxy-catalog.schema.json && npx json2ts schema/ado-proxy-catalog.schema.json -o src/shared/ado-proxy-catalog.types.gen.ts --bannerComment \"// AUTO-GENERATED from Rust via cargo run -- export-ado-proxy-catalog-schema. Do not edit; run npm run codegen.\" && cargo run --quiet --manifest-path ../../Cargo.toml -- export-ado-proxy-catalog --output src/ado-proxy/catalog.gen.json", "test": "vitest run", - "test:smoke": "npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && npm run build:exec-context-manual && npm run build:exec-context-pipeline && npm run build:exec-context-ci-push && npm run build:exec-context-workitem && npm run build:exec-context-schedule && npm run build:exec-context-pr-checks && npm run build:exec-context-repo && npm run build:conclusion && npm run build:approval-summary && npm run build:github-app-token && npm run build:prepare-pr-base && vitest run -c vitest.config.smoke.ts", + "test:smoke": "npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && npm run build:exec-context-manual && npm run build:exec-context-pipeline && npm run build:exec-context-ci-push && npm run build:exec-context-workitem && npm run build:exec-context-schedule && npm run build:exec-context-pr-checks && npm run build:exec-context-repo && npm run build:conclusion && npm run build:approval-summary && npm run build:github-app-token && npm run build:prepare-pr-base && npm run build:ado-proxy && vitest run -c vitest.config.smoke.ts", "lint": "echo TODO", "typecheck": "tsc --noEmit" }, diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/assertions.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/assertions.test.ts index 86e382efd..8e040bc3c 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/assertions.test.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/assertions.test.ts @@ -5,6 +5,7 @@ import { assertAdoTokenIsolation, assertNoForbiddenReleaseUrls, assertNoTriggers, + assertPipelineTextPolicy, assertPipelineArtifactValues, assertReleaseUrlsPresent, } from "../assertions.js"; @@ -117,6 +118,33 @@ describe("assertAgentCommandPolicy", () => { ).not.toThrow(); }); + describe("assertPipelineTextPolicy", () => { + it("accepts required snippets and absent forbidden snippets", () => { + expect(() => + assertPipelineTextPolicy( + "Start ado-proxy\n--network ado-aw-proxy-net", + "ado-proxy", + ["Start ado-proxy", "ado-aw-proxy-net"], + ["$SC_READ_TOKEN", "--network host"], + ), + ).not.toThrow(); + }); + + it("rejects a missing required or present forbidden snippet", () => { + expect(() => + assertPipelineTextPolicy("Start ado-proxy", "ado-proxy", ["Stop ado-proxy"], []), + ).toThrow(/missing required snippet/); + expect(() => + assertPipelineTextPolicy( + 'ADO_MCP_AUTH_TOKEN="$SC_READ_TOKEN"', + "ado-proxy", + [], + ["$SC_READ_TOKEN"], + ), + ).toThrow(/forbidden snippet/); + }); + }); + it("rejects unrestricted Agent tools", () => { const yaml = agentTokenYaml().replace( "echo agent", diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/cases.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/cases.test.ts index 5759bba3c..b0c6e64d5 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/cases.test.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/cases.test.ts @@ -313,7 +313,7 @@ describe("parseManifest", () => { }, ]), ), - ).toThrow(/must declare agentCommand and\/or requiredBuildTags/); + ).toThrow(/must declare agentCommand, pipelineText and\/or requiredBuildTags/); }); it("rejects an agentCommand with no snippets", () => { @@ -332,6 +332,30 @@ describe("parseManifest", () => { ), ).toThrow(/at least one snippet/); }); + + it("parses pipelineText assertions", () => { + const parsed = parseManifest( + cases([ + { + id: "x", + lane: "agentic", + kind: "compiled", + modes: ["candidate", "released"], + source: "a.md", + assertions: { + pipelineText: { + required: ["Start ado-proxy"], + forbidden: ["$SC_READ_TOKEN"], + }, + }, + }, + ]), + ); + expect(parsed.cases[0]?.assertions?.pipelineText).toEqual({ + required: ["Start ado-proxy"], + forbidden: ["$SC_READ_TOKEN"], + }); + }); }); }); diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts index c2834b9c7..ec960ec24 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts @@ -55,10 +55,19 @@ pr: none jobs: - job: Agent steps: - - bash: copilot --allow-tool "shell(az)" --allow-tool "shell(head)" + - bash: >- + copilot --allow-tool "shell(az)" --allow-tool "shell(head)" + --topology-attach "awmg-mcpg" + --topology-attach "awmg-ado-proxy" displayName: Run copilot (AWF network isolated) env: GITHUB_TOKEN: $(GITHUB_TOKEN) + - bash: | + echo '"ADO_MCP_AUTH_TOKEN": "ado-proxy-injects-the-real-credential"' + echo '"--network", "ado-aw-proxy-net",' + displayName: Start ado-proxy policy engine + - bash: echo stop + displayName: Stop ado-proxy - task: DownloadPipelineArtifact@2 inputs: targetPath: in @@ -86,7 +95,13 @@ vi.mock("../ado-rest.js", () => { return { name: "ado-aw-candidate" }; }), getBuild: vi.fn(async () => ({ status: "completed", result: "succeeded" })), - getBuildTags: vi.fn(async (buildId: number) => [`ado-aw-custom-job-${buildId}`]), + // The real manifest has two cases with runtime tag proofs. Returning + // both here keeps the generic build-id-only ADO mock independent of + // which case is currently being verified. + getBuildTags: vi.fn(async (buildId: number) => [ + `ado-aw-custom-job-${buildId}`, + `ado-aw-proxy-${buildId}`, + ]), queueBuild: vi.fn(async () => ({ id: 1 })), cancelBuild: vi.fn(async () => {}), addBuildTags: vi.fn(async () => {}), @@ -239,6 +254,7 @@ describe("smoke-e2e index.main (happy path, candidate mode)", () => { expect(queuedCaseIds).toEqual([ "canary", "azure-cli", + "ado-proxy", "noop-target", "custom-safe-output", "multi-repo", @@ -247,6 +263,7 @@ describe("smoke-e2e index.main (happy path, candidate mode)", () => { expect(compiledCasePaths).toEqual([ "tests/safe-outputs/canary.md", "tests/safe-outputs/azure-cli.md", + "tests/smoke/ado-proxy.md", "tests/safe-outputs/noop-target.md", "tests/smoke/custom-safe-output.md", "tests/smoke/multi-repo.md", @@ -265,12 +282,13 @@ describe("smoke-e2e index.main (happy path, candidate mode)", () => { expect(queuedRequests.map((r) => r.sourceBranch)).toEqual([ "refs/heads/ado-aw-smoke-candidate/630001/canary", "refs/heads/ado-aw-smoke-candidate/630001/azure-cli", + "refs/heads/ado-aw-smoke-candidate/630001/ado-proxy", "refs/heads/ado-aw-smoke-candidate/630001/noop-target", "refs/heads/ado-aw-smoke-candidate/630001/custom-safe-output", "refs/heads/ado-aw-smoke-candidate/630001/multi-repo", ]); // Every case is staged to the SAME path — the ref is what distinguishes them. - expect(stagedWrites.length).toBe(5); + expect(stagedWrites.length).toBe(6); for (const write of stagedWrites) { expect(write.to).toBe(join(WORKTREE, "candidate", ".smoke", "pipeline.yml")); // The compiler emits no trigger keys once `on:` is stripped, and a @@ -300,7 +318,7 @@ describe("smoke-e2e index.main (happy path, candidate mode)", () => { const gitModule = await import("../git.js"); const resets = vi.mocked(gitModule.resetWorktree).mock.calls; - expect(resets.length).toBe(5); + expect(resets.length).toBe(6); for (const call of resets) { expect(call[0]).toMatchObject({ commitish: "basecommit" }); } @@ -424,6 +442,7 @@ describe("smoke-e2e index.main (per-case ref retention)", () => { // build stranded every case's ref. expect(deletedRefs).toEqual([ "refs/heads/ado-aw-smoke-candidate/630001/canary", + "refs/heads/ado-aw-smoke-candidate/630001/ado-proxy", "refs/heads/ado-aw-smoke-candidate/630001/noop-target", "refs/heads/ado-aw-smoke-candidate/630001/custom-safe-output", "refs/heads/ado-aw-smoke-candidate/630001/multi-repo", diff --git a/scripts/ado-script/src/compiler-smoke-e2e/assertions.ts b/scripts/ado-script/src/compiler-smoke-e2e/assertions.ts index 794cea37d..76f51d49f 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/assertions.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/assertions.ts @@ -220,6 +220,25 @@ export function assertAgentCommandPolicy( } } +/** Assert required and forbidden snippets against the complete compiled YAML. */ +export function assertPipelineTextPolicy( + yamlText: string, + label: string, + requiredSnippets: readonly string[], + forbiddenSnippets: readonly string[], +): void { + for (const snippet of requiredSnippets) { + if (!yamlText.includes(snippet)) { + throw new Error(`${label}: compiled pipeline is missing required snippet '${snippet}'`); + } + } + for (const snippet of forbiddenSnippets) { + if (yamlText.includes(snippet)) { + throw new Error(`${label}: compiled pipeline contains forbidden snippet '${snippet}'`); + } + } +} + /** * Throws unless every `DownloadPipelineArtifact` "specific run" step in the * compiled YAML carries exactly the expected project/pipeline/runId/artifact diff --git a/scripts/ado-script/src/compiler-smoke-e2e/cases.ts b/scripts/ado-script/src/compiler-smoke-e2e/cases.ts index f2198188b..0dc4cfa3e 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/cases.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/cases.ts @@ -58,6 +58,8 @@ export interface AgentCommandAssertion { export interface CaseAssertions { /** Snippets that must / must not appear in the Agent execution step's bash body. */ readonly agentCommand?: AgentCommandAssertion; + /** Snippets that must / must not appear anywhere in the compiled pipeline YAML. */ + readonly pipelineText?: AgentCommandAssertion; /** Build tags the child run must carry, with `{buildId}` expanded to the child build id. */ readonly requiredBuildTags?: readonly string[]; } @@ -176,6 +178,27 @@ function parseAssertions(raw: unknown, caseId: string): CaseAssertions | undefin } } + let pipelineText: AgentCommandAssertion | undefined; + if (obj.pipelineText !== undefined) { + const pipeline = asRecord( + obj.pipelineText, + `case '${caseId}' assertions.pipelineText`, + ); + pipelineText = { + required: asStringArray( + pipeline.required ?? [], + `case '${caseId}' assertions.pipelineText.required`, + ), + forbidden: asStringArray( + pipeline.forbidden ?? [], + `case '${caseId}' assertions.pipelineText.forbidden`, + ), + }; + if (pipelineText.required.length === 0 && pipelineText.forbidden.length === 0) { + fail(`case '${caseId}' assertions.pipelineText must declare at least one snippet`); + } + } + let requiredBuildTags: string[] | undefined; if (obj.requiredBuildTags !== undefined) { requiredBuildTags = asStringArray( @@ -194,10 +217,16 @@ function parseAssertions(raw: unknown, caseId: string): CaseAssertions | undefin } } - if (agentCommand === undefined && requiredBuildTags === undefined) { - fail(`case '${caseId}' assertions must declare agentCommand and/or requiredBuildTags`); + if ( + agentCommand === undefined && + pipelineText === undefined && + requiredBuildTags === undefined + ) { + fail( + `case '${caseId}' assertions must declare agentCommand, pipelineText and/or requiredBuildTags`, + ); } - return { agentCommand, requiredBuildTags }; + return { agentCommand, pipelineText, requiredBuildTags }; } /** Expand `{buildId}` in a declared build tag. */ diff --git a/scripts/ado-script/src/compiler-smoke-e2e/index.ts b/scripts/ado-script/src/compiler-smoke-e2e/index.ts index bc854c556..7cf25d436 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/index.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/index.ts @@ -32,6 +32,7 @@ import { mkdir } from "node:fs/promises"; import { AdoRest } from "./ado-rest.js"; import { assertAgentCommandPolicy, + assertPipelineTextPolicy, assertAdoTokenIsolation, assertNoForbiddenReleaseUrls, assertNoTriggers, @@ -168,6 +169,10 @@ async function stageCase( if (agentCommand) { assertAgentCommandPolicy(yamlText, entry.id, agentCommand.required, agentCommand.forbidden); } + const pipelineText = entry.assertions?.pipelineText; + if (pipelineText) { + assertPipelineTextPolicy(yamlText, entry.id, pipelineText.required, pipelineText.forbidden); + } // The staged copy is byte-identical to the compiled lock, so the pipeline's // own runtime `ado-aw check ` integrity step still passes. Stripping diff --git a/tests/smoke/README.md b/tests/smoke/README.md index 9c9ff1daa..0060fef4f 100644 --- a/tests/smoke/README.md +++ b/tests/smoke/README.md @@ -35,7 +35,7 @@ credential class: | Lane | Secrets / service connections | Cases | | --- | --- | --- | -| `agentic` | `GITHUB_TOKEN`, `agent-playground-read`/`-write` | canary, azure-cli, noop-target, custom-safe-output, multi-repo, janitor | +| `agentic` | `GITHUB_TOKEN`, `agent-playground-read`/`-write` | canary, azure-cli, ado-proxy, noop-target, custom-safe-output, multi-repo, janitor | | `infra` | none | *(reserved for AWF and the ado-proxy sidecar)* | No case currently files GitHub issues, so the lane holds no GitHub PAT beyond @@ -174,14 +174,18 @@ Optional per-case assertions, so novel checks stay out of the harness code: ```jsonc "assertions": { "agentCommand": { "required": ["shell(az"], "forbidden": ["--allow-all-tools"] }, + "pipelineText": { + "required": ["displayName: Start ado-proxy policy engine"], + "forbidden": ["--network host"] + }, "requiredBuildTags": ["ado-aw-custom-job-{buildId}"] } ``` ### `kind: raw` -For pipelines that aren't compiled from front matter — the forthcoming AWF and -ado-proxy sidecar smokes. The source YAML is copied verbatim to +For pipelines that aren't compiled from front matter — for example a future +low-level AWF topology smoke that needs no compiler output. The source YAML is copied verbatim to `.smoke/pipeline.yml`; compile and artifact assertions are skipped, but `assertNoTriggers` still applies. Point it at the `infra` lane, which carries no GitHub token. diff --git a/tests/smoke/ado-proxy.md b/tests/smoke/ado-proxy.md new file mode 100644 index 000000000..bf9224091 --- /dev/null +++ b/tests/smoke/ado-proxy.md @@ -0,0 +1,77 @@ +--- +name: "ado-aw candidate smoke: credential-isolated ADO reads" +description: "Proves the real runner topology, wrapped az path, ADO MCP path, and proxy bearer injection" +target: standalone +pool: + name: AZS-1ES-L-Playground-ubuntu-22.04 +engine: + id: copilot + model: claude-sonnet-4.6 + timeout-minutes: 15 +tools: + bash: + - az + - head + edit: false + azure-devops: + org: msazuresphere + toolsets: [core] + allowed: [core_list_projects] +permissions: + read: + service-connection: agent-playground-read + capabilities: [core, repos] +safe-outputs: + add-build-tag: + tag-prefix: "ado-aw-proxy-" + max: 1 +--- + +## Candidate ado-proxy runner smoke + +You are a deterministic smoke test for credential-isolated Azure DevOps reads. +The real Azure DevOps bearer is held by `ado-proxy`; neither you, `az`, nor the +Azure DevOps MCP has it. + +Run these checks **in order**. If any check fails, stop without emitting a safe +output. The parent smoke orchestrator will fail because the proof tag is absent. + +1. Prove the generated `az` wrapper can read the current project through the + proxy: + + ```bash + az devops project show \ + --organization "$(System.CollectionUri)" \ + --project "$(System.TeamProject)" \ + --output json | head -40 + ``` + +2. Prove a GUID-addressed current-project read works through `az rest`: + + ```bash + az rest \ + --method get \ + --url "$(System.CollectionUri)_apis/projects/$(System.TeamProjectId)?api-version=7.1" \ + --output json | head -40 + ``` + +3. Prove the current repository is readable by repository GUID: + + ```bash + az rest \ + --method get \ + --url "$(System.CollectionUri)$(System.TeamProject)/_apis/git/repositories/$(Build.Repository.ID)/refs?api-version=7.1&filter=heads" \ + --output json | head -40 + ``` + +4. Invoke the Azure DevOps MCP tool `core_list_projects`. Confirm its response + includes `$(System.TeamProject)`. Use the native MCP tool interface, not + `curl`, raw HTTP, or shell. + +5. Only after all four reads succeed, invoke the `add-build-tag` safe-output + tool with: + + - `build_id`: `$(Build.BuildId)` + - `tag`: `$(Build.BuildId)` + +Do not invoke any other safe-output tool. Stop after emitting the tag. diff --git a/tests/smoke/cases.json b/tests/smoke/cases.json index cb5543b24..464b0862f 100644 --- a/tests/smoke/cases.json +++ b/tests/smoke/cases.json @@ -32,6 +32,35 @@ } } }, + { + "id": "ado-proxy", + "lane": "agentic", + "kind": "compiled", + "modes": ["candidate"], + "source": "tests/smoke/ado-proxy.md", + "assertions": { + "agentCommand": { + "required": [ + "--topology-attach \"awmg-mcpg\"", + "--topology-attach \"awmg-ado-proxy\"" + ] + }, + "pipelineText": { + "required": [ + "ado-proxy-injects-the-real-credential", + "\"--network\",", + "\"ado-aw-proxy-net\",", + "displayName: Start ado-proxy policy engine", + "displayName: Stop ado-proxy" + ], + "forbidden": [ + "-e ADO_MCP_AUTH_TOKEN=\"$SC_READ_TOKEN\"", + "--network host" + ] + }, + "requiredBuildTags": ["ado-aw-proxy-{buildId}"] + } + }, { "id": "noop-target", "lane": "agentic", From 3ee4442321dd60f80936fc71eb482fc7cba4021b Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 5 Aug 2026 14:20:26 +0100 Subject: [PATCH 32/42] fix(ado-proxy): detach the proxy lifecycle and publish diagnostics The first real runner smoke exposed a task-lifecycle bug: ado-proxy was started as an attached `docker run -i --rm ... &`. Azure Pipelines closed or cleaned up the inherited task streams after the start step, the process exited, and --rm deleted the container before AWF could attach it to awf-net. Start the container truly detached and block its entrypoint on a container-local FIFO. The host streams the versioned material document through docker exec -i; the FIFO stores no bytes, so the bearer still never enters a runner file, container layer, environment, or argv. Local proof across two independent host processes confirms the detached container remains running, listening, and publishes its CA after the launching process exits. Add auditable lifecycle diagnostics: startup waits for FIFO creation, material handover, listening state and published CA; failures print Docker state and log tails; a pre-AWF step verifies both trusted topology peers are still running; teardown captures container state/stdout; and proxy lifecycle plus sanitized decision logs are copied into the agent output artifact. Also generate an always-present Azure DevOps policy advisory from the same front matter as the runtime policy. It lists effective capabilities, explicit cross-organization scopes and repository-only repos: grants, explains that denials are policy results rather than authentication failures, and points operators at published sanitized decision logs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 79 +++++--- scripts/ado-script/src/ado-proxy/ca.ts | 13 +- .../__tests__/index.test.ts | 2 + src/compile/agentic_pipeline.rs | 177 ++++++++++++++++-- src/compile/extensions/azure_cli.rs | 160 +++++++++++++++- tests/smoke/cases.json | 1 + 6 files changed, 375 insertions(+), 57 deletions(-) diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index ec38e2721..48177c0d5 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -191,18 +191,23 @@ agent.** It does not follow that the key must be minted inside the engine's container — an earlier draft claimed that, and it was wrong. Two facts make a simpler arrangement safe. The engine starts *before* the AWF -invocation, so during CA setup no agent exists to read anything. And the -material can be passed on **stdin**, so it never touches a filesystem at all — -not the runner's, not the container's. +invocation, so during CA setup no agent process exists. The host step generates +private material under `$(Agent.TempDirectory)`, never runner `/tmp`, streams +it into the detached container, then shreds every private key. The bearer is +never written there: it exists only in the secret step environment and the +in-memory material document. Because the protected host set is compiler-known, the **leaves are generated -alongside the CA**, and the whole lot arrives as one PEM stream from a host -pipeline step: +alongside the CA**, and the whole lot arrives as one versioned JSON document: ```sh -# host step: mints CA + one leaf per protected host, straight to stdout -generate_ca_material \ - | docker run -i --name ado-proxy … node:20-slim ado-proxy.js +# container is detached and blocks on a private FIFO +docker run -d --name awmg-ado-proxy … sh -c \ + 'mkfifo /tmp/material; node /app/ado-proxy.js … < /tmp/material' + +# host streams the in-memory document; FIFO stores no bytes +printf '%s' "$PROXY_MATERIAL" \ + | docker exec -i awmg-ado-proxy sh -c 'cat > /tmp/material' ``` Generation runs directly on the runner with `openssl`, not in a helper @@ -212,12 +217,11 @@ run — so this adds no new dependency, no second image to pull, and nothing further for `supply-chain:` to mirror. A helper container would have been pure overhead. -Verified end to end: CA and all three leaves (`dev.azure.com`, -`app.vssps.visualstudio.com`, and the engine's own broker hostname) reach the -container, and a client verifies the served identity **as `dev.azure.com` -against the piped CA** (`authorized: true`). Piping configuration into a -container this way is the pattern MCPG already uses (`echo "$MCPG_CONFIG" | -docker run -i …`). +Verified end to end: CA and both protected-host leaves (`dev.azure.com` and +`app.vssps.visualstudio.com`) reach the container, and a client verifies the +served identity **as `dev.azure.com` against the published CA**. The detached +container was also proven to remain running after the independent host process +that started and fed it exited. `openssl` being absent is a hard failure, not a degradation: the step must exit non-zero rather than continue without an interception identity. @@ -227,9 +231,9 @@ non-zero rather than continue without an interception identity. Why this matters for the agent: AWF's chroot makes the agent's root the host's `/host` bind mount, so the agent's `/tmp` **is** the runner's `/tmp` — which is how AWF installs its own `gh` wrapper (`cp … /host/tmp/awf-lib/gh` appears -inside the chroot as `/tmp/awf-lib/gh`). A key written to a host path would -therefore be agent-readable. Keeping it on stdin sidesteps that entirely, rather -than relying on deleting it in time. +inside the chroot as `/tmp/awf-lib/gh`). A key written under runner `/tmp` would therefore be agent-readable. Private +keys instead use `$(Agent.TempDirectory)` only during the pre-agent setup step +and are shredded immediately after FIFO handover. Only the **public** certificate is written to a host path, so it can be mounted into the MCP container for `NODE_EXTRA_CA_CERTS`. @@ -385,18 +389,19 @@ Fail-closed behavior is structural rather than advisory: Custody rules the implementation enforces: -- the CA and its per-host leaves are minted at startup with the `openssl` - binary already present in the AWF agent image (Node can parse but not issue - X.509, and adding a certificate library would reintroduce the native - dependency this runtime exists to avoid). Every private key is written only - under the container tmpfs directory AWF mounts for this purpose; only the - public CA PEM is copied out, into the file AWF pre-creates and bind-mounts - read-only into the agent; -- the bearer is read from its private file, cached on the file's mtime and - size, so a rotation is observed on the next request and a removed or emptied - file immediately becomes an infrastructure failure rather than a stale - credential. It is applied to a copy of the sanitized header set *after* the - allow decision, so no code path can emit it for a denied request; +- the host step mints the CA and per-host leaves with the runner's existing + `openssl`. Private keys live only under `$(Agent.TempDirectory)` — never + runner `/tmp`, which AWF exposes inside the agent chroot — and are shredded + immediately after handover. Only the public CA PEM is published under + `/tmp/ado-aw-lib` for the MCP and wrapped `az`; +- the bearer and certificate material form one versioned JSON document. The + proxy container starts detached, blocks on a container-local FIFO, and the + host streams the document through `docker exec -i`. The FIFO stores no + bytes, and no bearer enters a runner file, container layer, environment, or + argv. The engine reads it once and keeps the bearer in memory for the + compile-time-bounded run. It applies the bearer to a copy of the sanitized + header set *after* the allow decision, so no code path can emit it for a + denied request; - the JSONL decision log is schema-versioned and carries only the timestamp, request id, protected host, method, normalized operation id, decision, machine-readable reason and short detail, upstream status class, latency, @@ -404,6 +409,22 @@ Custody rules the implementation enforces: supplied and the proxy stripped. Raw paths, query values, headers, bodies, and credentials have nowhere to go in the record type. +Operational diagnostics are equally deliberate: + +- startup waits for the private FIFO, completed material parse, published CA, + listening log line, and container IP — not merely for `docker run` to return; +- immediately before AWF starts, a preflight verifies both externally launched + topology peers (`awmg-mcpg` and `awmg-ado-proxy`) are still running. A missing + peer prints Docker state and the last 200 log lines instead of deferring to + AWF's opaque `No such container`; +- teardown captures Docker lifecycle state/stdout, while sanitized decision + JSONL and lifecycle logs are copied into + `agent_outputs_/logs/ado-proxy`; +- the generated agent prompt lists effective capabilities and scopes from the + same front matter that produced the policy document, so predictable + prompt/config conflicts are visible before the agent attempts an impossible + request. Runtime denials still return the machine-readable policy reason. + ## Evidence Findings from driving the real Azure CLI against the implemented engine. These diff --git a/scripts/ado-script/src/ado-proxy/ca.ts b/scripts/ado-script/src/ado-proxy/ca.ts index 73c14305d..dac9bd3ad 100644 --- a/scripts/ado-script/src/ado-proxy/ca.ts +++ b/scripts/ado-script/src/ado-proxy/ca.ts @@ -7,13 +7,12 @@ * per-host leaves, and the bearer straight into `docker run -i`. Two * consequences: * - * - **No private key or credential touches a filesystem.** Not the runner's, - * not the container's. AWF's chroot makes the agent's root the host's - * `/host` bind mount, so the agent's `/tmp` *is* the runner's `/tmp`; - * anything written to a runner path would be agent-readable. Keeping the - * material on stdin sidesteps that rather than relying on deleting it in - * time. There is no exposure window either, because the engine starts - * before AWF — at generation time no agent exists at all. + * - **No bearer touches a filesystem, and no private key touches runner + * `/tmp`.** The host generates keys under `$(Agent.TempDirectory)`, + * streams them with the bearer through a container-local FIFO, and shreds + * them immediately after handover. AWF exposes runner `/tmp` inside the + * agent chroot, so using that path would make private material readable by + * the agent. The FIFO itself stores no bytes. * - **The engine needs no `openssl`,** so it runs on `node:20-slim` (which * has none) rather than the full `node:20`. That is already the image the * Azure DevOps MCP uses, so it adds nothing to mirror. diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts index ec960ec24..f92a44dd4 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts @@ -66,6 +66,8 @@ jobs: echo '"ADO_MCP_AUTH_TOKEN": "ado-proxy-injects-the-real-credential"' echo '"--network", "ado-aw-proxy-net",' displayName: Start ado-proxy policy engine + - bash: echo peers running + displayName: Verify trusted topology peers - bash: echo stop displayName: Stop ado-proxy - task: DownloadPipelineArtifact@2 diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 244720975..83581bfdd 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -1157,6 +1157,14 @@ fn build_agent_job( front_matter.supply_chain(), )?)); + // Both peers must still exist immediately before AWF creates `awf-net`. + // This catches a detached-process/lifecycle regression here, with each + // container's own logs, instead of letting AWF fail later with only + // "No such container". + if ado_proxy_enabled { + steps.push(Step::Bash(verify_trusted_topology_peers_step())); + } + // 16. Verify MCP backends (debug-only) if cfg.debug_pipeline { steps.push(Step::Bash(verify_mcp_backends_step())); @@ -4300,22 +4308,49 @@ fn start_ado_proxy_step(front_matter: &FrontMatter) -> BashStep { docker rm -f {ADO_PROXY_CONTAINER_NAME} 2>/dev/null || true\n\ mkdir -p /tmp/gh-aw/ado-proxy-logs\n\ \n\ - printf '%s' \"$PROXY_MATERIAL\" | docker run -i --rm \\\n \ + # Start detached so the container lifetime belongs to Docker, not to\n\ + # this Bash task's attached STDIO. Azure Pipelines cleans up inherited\n\ + # child streams between tasks; an attached `docker run -i ... &` was\n\ + # observed to exit and `--rm` itself before AWF could attach it.\n\ + #\n\ + # A container-local FIFO preserves the stdin-only custody contract:\n\ + # material is streamed through `docker exec -i`, never written to a\n\ + # runner path, container layer, argv, or environment.\n\ + docker run -d \\\n \ --name {ADO_PROXY_CONTAINER_NAME} \\\n \ --network {ADO_PROXY_NETWORK_NAME} \\\n \ + --entrypoint sh \\\n \ -v \"{ado_proxy_path}:/app/ado-proxy.js:ro\" \\\n \ -v \"$PROXY_DIR/policy:/etc/ado-proxy:ro\" \\\n \ -v /tmp/ado-aw-lib:/var/lib/ado-proxy \\\n \ -v /tmp/gh-aw/ado-proxy-logs:/var/log/ado-proxy \\\n \ {ado_proxy_image} \\\n \ - node /app/ado-proxy.js \\\n \ - --policy-file /etc/ado-proxy/policy.json \\\n \ - --public-ca-file /var/lib/ado-proxy/ado-proxy-ca.pem \\\n \ - --upstream-proxy {squid_url} \\\n \ - --listen-port {listen_port} \\\n \ - --tls-port {tls_port} \\\n \ - --log-dir /var/log/ado-proxy \\\n \ - > /tmp/gh-aw/ado-proxy-logs/stdout.log 2>&1 &\n\ + -c 'set -eu; umask 077; MATERIAL_FIFO=/tmp/ado-proxy-material; mkfifo \"$MATERIAL_FIFO\"; exec node /app/ado-proxy.js --policy-file /etc/ado-proxy/policy.json --public-ca-file /var/lib/ado-proxy/ado-proxy-ca.pem --upstream-proxy {squid_url} --listen-port {listen_port} --tls-port {tls_port} --log-dir /var/log/ado-proxy < \"$MATERIAL_FIFO\"' \\\n \ + >/dev/null\n\ + \n\ + # Wait until the detached container is blocked on its private FIFO,\n\ + # then hand over the one-shot material. A transfer failure prints the\n\ + # durable Docker log and container state before failing the pipeline.\n\ + FIFO_READY=false\n\ + for _i in $(seq 1 30); do\n \ + if docker exec {ADO_PROXY_CONTAINER_NAME} test -p /tmp/ado-proxy-material 2>/dev/null; then\n \ + FIFO_READY=true\n \ + break\n \ + fi\n \ + sleep 1\n\ + done\n\ + if [ \"$FIFO_READY\" != \"true\" ]; then\n \ + echo \"##vso[task.logissue type=error]ado-proxy container did not create its private material channel\"\n \ + docker inspect -f 'state={{{{.State.Status}}}} exit={{{{.State.ExitCode}}}} error={{{{.State.Error}}}}' {ADO_PROXY_CONTAINER_NAME} 2>/dev/null || true\n \ + docker logs --tail 200 {ADO_PROXY_CONTAINER_NAME} 2>&1 || true\n \ + exit 1\n\ + fi\n\ + if ! printf '%s' \"$PROXY_MATERIAL\" | docker exec -i {ADO_PROXY_CONTAINER_NAME} sh -c 'cat > /tmp/ado-proxy-material'; then\n \ + echo \"##vso[task.logissue type=error]ado-proxy material handover failed\"\n \ + docker inspect -f 'state={{{{.State.Status}}}} exit={{{{.State.ExitCode}}}} error={{{{.State.Error}}}}' {ADO_PROXY_CONTAINER_NAME} 2>/dev/null || true\n \ + docker logs --tail 200 {ADO_PROXY_CONTAINER_NAME} 2>&1 || true\n \ + exit 1\n\ + fi\n\ \n\ # Drop the private material as soon as it has been handed over. The\n\ # container has it in memory; nothing else needs it again.\n\ @@ -4323,24 +4358,31 @@ fn start_ado_proxy_step(front_matter: &FrontMatter) -> BashStep { unset PROXY_MATERIAL\n\ shred -u \"$PROXY_DIR/ca.key\" \"$PROXY_DIR\"/*.key 2>/dev/null || rm -f \"$PROXY_DIR/ca.key\" \"$PROXY_DIR\"/*.key\n\ \n\ - # Resolve the container IP so the ADO MCP can be redirected at it.\n\ + # Resolve the container IP only after the engine has parsed policy,\n\ + # published its public CA and reached its listening state.\n\ PROXY_READY=false\n\ - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop\n\ - for i in $(seq 1 30); do\n \ + for _i in $(seq 1 30); do\n \ + PROXY_STATE=$(docker inspect -f '{{{{.State.Status}}}}' {ADO_PROXY_CONTAINER_NAME} 2>/dev/null || true)\n \ + if [ \"$PROXY_STATE\" = \"exited\" ] || [ \"$PROXY_STATE\" = \"dead\" ]; then\n \ + break\n \ + fi\n \ ADO_PROXY_IP=$(docker inspect -f '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}}{{{{end}}}}' {ADO_PROXY_CONTAINER_NAME} 2>/dev/null || true)\n \ - if [ -n \"$ADO_PROXY_IP\" ]; then\n \ + if [ -n \"$ADO_PROXY_IP\" ] \\\n \ + && [ -f {ca_host_path} ] \\\n \ + && docker logs {ADO_PROXY_CONTAINER_NAME} 2>&1 | grep -q '\\[ado-proxy\\] listening'; then\n \ PROXY_READY=true\n \ break\n \ fi\n \ sleep 1\n\ done\n\ if [ \"$PROXY_READY\" != \"true\" ]; then\n \ - echo \"ado-proxy log tail:\"\n \ - cat /tmp/gh-aw/ado-proxy-logs/stdout.log 2>/dev/null || true\n \ - echo \"##vso[task.complete result=Failed]ado-proxy did not start within 30s\"\n \ + echo \"##vso[task.logissue type=error]ado-proxy did not become ready within 30s (state=${{PROXY_STATE:-missing}})\"\n \ + docker inspect -f 'state={{{{.State.Status}}}} exit={{{{.State.ExitCode}}}} error={{{{.State.Error}}}}' {ADO_PROXY_CONTAINER_NAME} 2>/dev/null || true\n \ + docker logs --tail 200 {ADO_PROXY_CONTAINER_NAME} 2>&1 || true\n \ exit 1\n\ fi\n\ echo \"ado-proxy is ready at $ADO_PROXY_IP\"\n\ + docker logs --tail 1 {ADO_PROXY_CONTAINER_NAME} 2>&1 || true\n\ echo \"##vso[task.setvariable variable=ADO_PROXY_IP]$ADO_PROXY_IP\"\n", org_resolve = common::resolve_ado_organization_bash(" "), ado_proxy_path = paths::ADO_PROXY_PATH, @@ -4370,7 +4412,19 @@ fn start_ado_proxy_step(front_matter: &FrontMatter) -> BashStep { /// keeps that change to the wiring alone. fn stop_ado_proxy_step() -> BashStep { let script = format!( - "# Stop the ado-proxy policy engine\n\ + "# Preserve auditable lifecycle output before stopping the policy engine\n\ + mkdir -p /tmp/gh-aw/ado-proxy-logs\n\ + if docker inspect {ADO_PROXY_CONTAINER_NAME} >/dev/null 2>&1; then\n \ + docker inspect -f 'state={{{{.State.Status}}}} exit={{{{.State.ExitCode}}}} error={{{{.State.Error}}}}' {ADO_PROXY_CONTAINER_NAME} \\\n \ + > /tmp/gh-aw/ado-proxy-logs/container-state.txt 2>&1 || true\n \ + docker logs {ADO_PROXY_CONTAINER_NAME} \\\n \ + > /tmp/gh-aw/ado-proxy-logs/container.log 2>&1 || true\n\ + else\n \ + echo 'state=missing before teardown' > /tmp/gh-aw/ado-proxy-logs/container-state.txt\n\ + echo \"##vso[task.logissue type=warning]ado-proxy container was already missing at teardown; inspect the preflight/AWF step and ado-proxy log artifact\"\n\ + fi\n\ + \n\ + # Stop the ado-proxy policy engine\n\ echo \"Stopping ado-proxy...\"\n\ docker stop {ADO_PROXY_CONTAINER_NAME} 2>/dev/null || true\n\ docker rm -f {ADO_PROXY_CONTAINER_NAME} 2>/dev/null || true\n\ @@ -4379,6 +4433,37 @@ fn stop_ado_proxy_step() -> BashStep { bash("Stop ado-proxy", script).with_condition(Condition::Always) } +/// Verify externally-launched peers still exist immediately before AWF tries +/// to attach them to its internal network. +/// +/// A peer may start successfully and disappear between pipeline tasks if its +/// lifecycle accidentally remains attached to the launching task's STDIO. +/// Reporting the peer's Docker state and logs here turns AWF's otherwise opaque +/// "No such container" error into an actionable startup/lifecycle failure. +fn verify_trusted_topology_peers_step() -> BashStep { + let script = format!( + "set -euo pipefail\n\ + mkdir -p /tmp/gh-aw/ado-proxy-logs\n\ + for PEER in {MCPG_CONTAINER_NAME} {ADO_PROXY_CONTAINER_NAME}; do\n \ + PEER_STATE=$(docker inspect -f '{{{{.State.Status}}}}' \"$PEER\" 2>/dev/null || true)\n \ + if [ \"$PEER_STATE\" != \"running\" ]; then\n \ + echo \"##vso[task.logissue type=error]trusted topology peer $PEER is not running before AWF attachment (state=${{PEER_STATE:-missing}})\"\n \ + docker ps -a --filter \"name=^/${{PEER}}$\" --no-trunc || true\n \ + if [ \"$PEER\" = \"{ADO_PROXY_CONTAINER_NAME}\" ]; then\n \ + docker logs --tail 200 \"$PEER\" 2>&1 \\\n \ + | tee /tmp/gh-aw/ado-proxy-logs/container.log || true\n \ + else\n \ + docker logs --tail 200 \"$PEER\" 2>&1 || true\n \ + fi\n \ + exit 1\n \ + fi\n \ + echo \"Trusted topology peer $PEER is running\"\n \ + done\n\ + echo \"ado-proxy policy and client configuration are ready; runtime denials will include the policy reason and sanitized decision logs\"\n" + ); + bash("Verify trusted topology peers", script) +} + fn copy_logs_step(engine_log_dir: &str, is_detection: bool) -> BashStep { if is_detection { // Detection job copies its logs into analyzed_outputs/logs (the @@ -4414,6 +4499,10 @@ fn copy_logs_step(engine_log_dir: &str, is_detection: bool) -> BashStep { mkdir -p \"$(Agent.TempDirectory)/staging/logs/mcpg\"\n \ cp -r /tmp/gh-aw/mcp-logs/* \"$(Agent.TempDirectory)/staging/logs/mcpg/\" 2>/dev/null || true\n\ fi\n\ + if [ -d /tmp/gh-aw/ado-proxy-logs ]; then\n \ + mkdir -p \"$(Agent.TempDirectory)/staging/logs/ado-proxy\"\n \ + cp -r /tmp/gh-aw/ado-proxy-logs/* \"$(Agent.TempDirectory)/staging/logs/ado-proxy/\" 2>/dev/null || true\n \ + fi\n\ echo \"Logs copied to $(Agent.TempDirectory)/staging/logs\"\n\ ls -la \"$(Agent.TempDirectory)/staging/logs\" 2>/dev/null || echo \"No logs found\"\n" ); @@ -5863,8 +5952,11 @@ safe-outputs: ); for private in ["ca.key", "$ADO_PROXY_BEARER", "PROXY_MATERIAL"] { for line in script.lines().filter(|line| line.contains(private)) { + let container_private_fifo = line.contains("docker exec -i") + && line.contains("/tmp/ado-proxy-material"); assert!( - !line.contains("/tmp/gh-aw") && !line.contains("> /tmp"), + !line.contains("/tmp/gh-aw") + && (!line.contains("> /tmp") || container_private_fifo), "{private} must never be written under /tmp: {line}" ); } @@ -5876,10 +5968,17 @@ safe-outputs: let step = start_ado_proxy_step(&proxy_fm()); assert!( - step.script.contains("printf '%s' \"$PROXY_MATERIAL\" | docker run -i"), - "material must arrive on stdin: {}", + step.script.contains( + "printf '%s' \"$PROXY_MATERIAL\" | docker exec -i awmg-ado-proxy" + ) && step.script.contains("cat > /tmp/ado-proxy-material"), + "material must stream through the container-private FIFO: {}", step.script ); + assert!( + step.script.contains("docker run -d") + && step.script.contains("mkfifo \"$MATERIAL_FIFO\""), + "the container must be detached from the Bash task before material handover" + ); // A `-e` would put it in the container's `Env`, readable by anyone who // can call `docker inspect`; an argv flag would expose it in the // process table. @@ -5893,6 +5992,44 @@ safe-outputs: ); } + #[test] + fn ado_proxy_container_lifecycle_is_independent_of_the_start_task() { + let script = start_ado_proxy_step(&proxy_fm()).script; + assert!(script.contains("docker run -d")); + assert!( + !script.contains("docker run -i --rm"), + "attached --rm containers disappear when Azure Pipelines cleans up task STDIO" + ); + assert!(script.contains("docker logs --tail 200")); + assert!(script.contains("state={{.State.Status}} exit={{.State.ExitCode}}")); + } + + #[test] + fn trusted_topology_preflight_reports_missing_peer_logs() { + let step = verify_trusted_topology_peers_step(); + assert!(step.script.contains(MCPG_CONTAINER_NAME)); + assert!(step.script.contains(ADO_PROXY_CONTAINER_NAME)); + assert!(step.script.contains("trusted topology peer $PEER is not running")); + assert!(step.script.contains("docker logs --tail 200")); + assert_eq!(step.display_name, "Verify trusted topology peers"); + } + + #[test] + fn ado_proxy_lifecycle_and_decision_logs_are_published() { + let stop = stop_ado_proxy_step(); + assert!(stop.script.contains("container-state.txt")); + assert!(stop.script.contains("container.log")); + assert!(stop.script.contains("already missing at teardown")); + + let copy = copy_logs_step("/tmp/copilot", false); + assert!(copy.script.contains("/tmp/gh-aw/ado-proxy-logs")); + assert!( + copy.script + .contains("$(Agent.TempDirectory)/staging/logs/ado-proxy"), + "proxy lifecycle and sanitized decision logs must reach the agent artifact" + ); + } + #[test] fn ado_proxy_destroys_the_signing_key_after_handover() { let script = start_ado_proxy_step(&proxy_fm()).script; diff --git a/src/compile/extensions/azure_cli.rs b/src/compile/extensions/azure_cli.rs index 4be7d1678..b72836112 100644 --- a/src/compile/extensions/azure_cli.rs +++ b/src/compile/extensions/azure_cli.rs @@ -89,6 +89,13 @@ impl CompilerExtension for AzureCliExtension { // Installed before the prompt is appended so the advisory and the // wrapper cannot describe different worlds. agent_prepare_steps.push(Step::Bash(install_az_wrapper_step(&capabilities))); + // This advisory is independent of `az` detection: the same policy + // governs MCP reads, and the agent must understand effective + // front-matter scope even on a runner without Azure CLI. + agent_prepare_steps.push(Step::Bash(proxy_policy_prompt_step( + ctx.front_matter, + &capabilities, + ))); } agent_prepare_steps.push(Step::Bash(prompt_append_bash_step(proxied, &capabilities))); @@ -169,6 +176,93 @@ fn detection_bash_step() -> BashStep { BashStep::new("Detect Azure CLI on host (for AWF mount)", script) } +/// Explain the effective compiler-owned ADO read policy to the agent. +/// +/// This is generated from the same front matter as `PolicyDocument`, so prompt +/// guidance cannot claim a scope the runtime denies (or hide one it allows). +/// Runtime denial responses and the sanitized decision log remain +/// authoritative; this text prevents predictable prompt/config conflicts +/// before the agent starts retrying an impossible request. +fn proxy_policy_prompt_step( + front_matter: &crate::compile::types::FrontMatter, + capabilities: &[Capability], +) -> BashStep { + let capability_list = capabilities + .iter() + .map(|capability| format!("`{}`", capability.as_str())) + .collect::>() + .join(", "); + + let mut scope_lines = vec![ + "- Current organization, project, and repository (by name or GUID).".to_string(), + ]; + if let Some(options) = front_matter + .permissions + .as_ref() + .and_then(|permissions| permissions.read.as_ref()) + .and_then(crate::compile::types::ReadPermissionConfig::options) + { + for organization in &options.allow { + for project in &organization.projects { + let repositories = if project.repositories.is_empty() { + "project-scoped reads; no repository-scoped reads".to_string() + } else { + format!( + "project-scoped reads; repositories: {}", + project + .repositories + .iter() + .map(|repository| format!("`{}`", repository.as_str())) + .collect::>() + .join(", ") + ) + }; + scope_lines.push(format!( + "- Additional `{}/{}` ({repositories}).", + organization.organization.as_str(), + project.project.as_str(), + )); + } + } + } + for repository in &front_matter.repositories { + if repository.repo_type.eq_ignore_ascii_case("git") + && let Some((project, name)) = repository.name.split_once('/') + { + scope_lines.push(format!( + "- Repository-only `{project}/{name}` from `repos:`; this does **not** grant project work items, builds, or pipelines." + )); + } + } + let scope_list = scope_lines.join("\n"); + + let body = format!( + "\n\ +---\n\ +\n\ +## Azure DevOps read policy\n\ +\n\ +Azure DevOps reads are routed through a credential-isolated policy proxy. You, `az`, and the Azure DevOps MCP have no real Azure DevOps credential.\n\ +\n\ +**Enabled capabilities:** {capability_list}\n\ +\n\ +**Allowed scopes:**\n\ +{scope_list}\n\ +\n\ +Requests outside these capabilities or scopes, all writes, and secret-bearing route families are deliberately refused. A refusal is a policy result, not an authentication problem: do not sign in, change the URL, or retry it as a workaround. The error response names the denial reason, and sanitized proxy decision logs are published with the run for operators.\n\ +\n\ +If your task requires a read outside this list, report it as missing data/tooling and name the exact organization, project, repository, and operation that the front matter would need to grant.\n" + ); + let script = format!( + "cat >> \"/tmp/awf-tools/agent-prompt.md\" << 'ADO_PROXY_POLICY_PROMPT_EOF'\n\ +{body}\ +ADO_PROXY_POLICY_PROMPT_EOF\n\ +\n\ +echo \"ado-proxy policy prompt appended\"\n" + ); + BashStep::new("Append ado-proxy policy prompt", script) +} + /// Append an Azure CLI advisory when the detection step found `az`. /// /// Two quite different messages, because the agent's actual capability differs. @@ -193,7 +287,7 @@ fn prompt_append_bash_step(proxied: bool, capabilities: &[Capability]) -> BashSt The Azure CLI is available and **pre-configured for Azure DevOps reads**. You do not need to sign in, and no credential is present in this sandbox for you to use or leak.\n\ \n\ - **Available** — {group_list}, scoped to the current organization and project. These are **read-only**: listing and getting work, and the results are real. `az rest` and `az devops invoke` also work for Azure DevOps reads, so a catalogued endpoint without a dedicated command is still reachable.\n\ -- **Not available** — creating, updating or deleting anything; reading secrets (service connections, variable groups, secure files, tokens, permissions); any other organization or project; and every other `az` command group, including Azure Resource Manager (`az resource`, `az account`, `az group`) and Microsoft Graph (`az ad`).\n\ +- **Not available** — creating, updating or deleting anything; reading secrets (service connections, variable groups, secure files, tokens, permissions); scopes not listed in the Azure DevOps read policy above; and every other `az` command group, including Azure Resource Manager (`az resource`, `az account`, `az group`) and Microsoft Graph (`az ad`).\n\ \n\ Requests outside that boundary are refused by a policy proxy, not by a misconfiguration — retrying, changing the URL, or trying to authenticate will not help. To *change* anything, emit a safe output instead; that is the supported path for writes.\n\ \n\ @@ -259,6 +353,20 @@ mod tests { .next() } + fn policy_prompt_step(front_matter: &FrontMatter) -> Option { + let ctx = CompileContext::for_test(front_matter); + AzureCliExtension + .declarations(&ctx) + .unwrap() + .agent_prepare_steps + .into_iter() + .filter_map(|step| match step { + Step::Bash(b) if b.display_name.contains("policy prompt") => Some(b), + _ => None, + }) + .next() + } + #[test] fn the_wrapper_is_installed_only_when_traffic_is_policed() { // Without the policy engine there is nothing to redirect to, and @@ -323,6 +431,56 @@ mod tests { assert!(step.script.contains("<< 'ADO_AW_AZ_WRAPPER_EOF'")); } + #[test] + fn the_policy_prompt_is_present_even_when_az_is_not_detected() { + let step = policy_prompt_step(&fm_proxied()).expect("policy prompt"); + assert!( + step.condition.is_none(), + "MCP reads use the same policy, so policy feedback must not depend on az detection" + ); + assert!(step.script.contains("Enabled capabilities:")); + assert!(step.script.contains("sanitized proxy decision logs")); + } + + #[test] + fn the_policy_prompt_lists_explicit_and_repository_only_scopes() { + let mut front_matter = crate::compile::parse_markdown( + r#"--- +name: t +description: x +tools: + azure-devops: + org: myorg +permissions: + read: + service-connection: sc + capabilities: [core, repos] + allow: + - organization: fabrikam + projects: + - project: Shared + repositories: [shared-api] +repos: + - LocalProject/implicit-api +--- +"#, + ) + .unwrap() + .0; + let (repositories, checkout, fetch) = + crate::compile::resolve_repos(&front_matter).unwrap(); + front_matter.repositories = repositories; + front_matter.checkout = checkout; + front_matter.checkout_fetch = fetch; + + let step = policy_prompt_step(&front_matter).expect("policy prompt"); + assert!(step.script.contains("`fabrikam/Shared`")); + assert!(step.script.contains("repositories: `shared-api`")); + assert!(step.script.contains("Repository-only `LocalProject/implicit-api`")); + assert!(step.script.contains("does **not** grant project")); + assert!(step.script.contains("`discovery`, `core`, `repos`")); + } + fn agent_prepare_steps(ext: &AzureCliExtension, ctx: &CompileContext<'_>) -> Vec { ext.declarations(ctx).unwrap().agent_prepare_steps } diff --git a/tests/smoke/cases.json b/tests/smoke/cases.json index 464b0862f..5e29960c2 100644 --- a/tests/smoke/cases.json +++ b/tests/smoke/cases.json @@ -51,6 +51,7 @@ "\"--network\",", "\"ado-aw-proxy-net\",", "displayName: Start ado-proxy policy engine", + "displayName: Verify trusted topology peers", "displayName: Stop ado-proxy" ], "forbidden": [ From 1f31bb9bd4a9044e2ad7dc5ce9267e5b80f7c479 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 5 Aug 2026 15:08:01 +0100 Subject: [PATCH 33/42] fix(ado-proxy): publish the interception CA as world-readable The second real runner smoke crossed the topology boundary but every wrapped az call failed before reaching the proxy: the container's deliberate umask 077 filtered writeFileSync(mode: 0644) to root-owned 0600, so the non-root AWF agent received PermissionError(13) opening the public CA. Explicitly chmod the intentionally public certificate to 0644 after creation. The strict umask still protects any accidentally-created private material, but the wrapped az process and MCP can read the one file designed for them. Extend the pre-AWF topology check to require and report a readable CA, including its mode and proxy log tail, so this class of prompt/runtime conflict fails immediately with operator-actionable feedback instead of consuming an agent run on repeated authentication and SSL retries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 3 ++ scripts/ado-script/src/ado-proxy/ca.test.ts | 32 ++++++++++++++++++++- scripts/ado-script/src/ado-proxy/ca.ts | 8 +++++- src/compile/agentic_pipeline.rs | 14 ++++++++- 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index 48177c0d5..8965bf00f 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -413,6 +413,9 @@ Operational diagnostics are equally deliberate: - startup waits for the private FIFO, completed material parse, published CA, listening log line, and container IP — not merely for `docker run` to return; +- preflight also verifies that the intentionally public CA is readable by the + runner/agent identity and reports its mode. This catches a restrictive + container umask before `az` spends a run retrying `PermissionError(13)`; - immediately before AWF starts, a preflight verifies both externally launched topology peers (`awmg-mcpg` and `awmg-ado-proxy`) are still running. A missing peer prints Docker state and the last 200 log lines instead of deferring to diff --git a/scripts/ado-script/src/ado-proxy/ca.test.ts b/scripts/ado-script/src/ado-proxy/ca.test.ts index c4874c97f..30a6df8cb 100644 --- a/scripts/ado-script/src/ado-proxy/ca.test.ts +++ b/scripts/ado-script/src/ado-proxy/ca.test.ts @@ -6,7 +6,14 @@ * of a toolchain dependency. Real material is exercised end to end in * `proxy.e2e.test.ts`. */ -import { mkdtempSync, rmSync, writeFileSync, openSync, closeSync } from "node:fs"; +import { + closeSync, + mkdtempSync, + openSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -147,6 +154,29 @@ describe("publishCaCertificate", () => { expect(() => publishCaCertificate(join(directory, "ca.pem"), CERT)).not.toThrow(); }); + it("makes the public certificate readable despite a restrictive umask", () => { + const path = join(directory, "ca.pem"); + const previous = process.umask(0o077); + try { + publishCaCertificate(path, CERT); + } finally { + process.umask(previous); + } + + // The non-root AWF agent and MCP child need read access. An observed runner + // failure left this file at 0600 when writeFileSync's mode was filtered + // through umask 077. + const mode = statSync(path).mode & 0o777; + if (process.platform === "win32") { + // Windows exposes only the read-only attribute through chmod/stat; Node + // commonly reports 0666 here. All three read bits are the portable + // invariant, while the Linux runner must be exactly 0644. + expect(mode & 0o444).toBe(0o444); + } else { + expect(mode).toBe(0o644); + } + }); + it("refuses to publish anything containing a private key", () => { // This path is mounted into the MCP container; a key reaching it would hand // out the ability to impersonate any protected host. diff --git a/scripts/ado-script/src/ado-proxy/ca.ts b/scripts/ado-script/src/ado-proxy/ca.ts index dac9bd3ad..1125c1b63 100644 --- a/scripts/ado-script/src/ado-proxy/ca.ts +++ b/scripts/ado-script/src/ado-proxy/ca.ts @@ -46,7 +46,7 @@ * the marker text could fabricate a section — and duplicate sections resolved * silently to the last occurrence. */ -import { readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, readFileSync, writeFileSync } from "node:fs"; export class CaError extends Error {} @@ -226,4 +226,10 @@ export function publishCaCertificate(path: string, caCertPem: string): void { throw new CaError("refusing to publish certificate material containing a private key"); } writeFileSync(path, caCertPem, { mode: 0o644 }); + // `mode` is filtered through the process umask. The container deliberately + // starts under `umask 077` so any accidentally-created private material is + // owner-only; that also turns the public CA into 0600 unless we explicitly + // correct it after creation. The MCP mount and the non-root AWF agent both + // need read access to this certificate. + chmodSync(path, 0o644); } diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 83581bfdd..271940262 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -4459,7 +4459,17 @@ fn verify_trusted_topology_peers_step() -> BashStep { fi\n \ echo \"Trusted topology peer $PEER is running\"\n \ done\n\ - echo \"ado-proxy policy and client configuration are ready; runtime denials will include the policy reason and sanitized decision logs\"\n" + if [ ! -r {ca_host_path} ]; then\n \ + echo \"##vso[task.logissue type=error]ado-proxy public CA is not readable by the runner/agent identity: {ca_host_path}\"\n \ + ls -l {ca_host_path} 2>&1 || true\n \ + echo \"The proxy publishes this intentionally public certificate for the wrapped az process and Azure DevOps MCP. A restrictive container umask must not leave it owner-only.\"\n \ + docker logs --tail 200 {ADO_PROXY_CONTAINER_NAME} 2>&1 || true\n \ + exit 1\n \ + fi\n\ + CA_MODE=$(stat -c '%a' {ca_host_path} 2>/dev/null || echo unknown)\n\ + echo \"ado-proxy public CA is readable (mode=$CA_MODE)\"\n\ + echo \"ado-proxy policy and client configuration are ready; runtime denials will include the policy reason and sanitized decision logs\"\n", + ca_host_path = ADO_PROXY_PUBLIC_CA_HOST_PATH ); bash("Verify trusted topology peers", script) } @@ -6011,6 +6021,8 @@ safe-outputs: assert!(step.script.contains(ADO_PROXY_CONTAINER_NAME)); assert!(step.script.contains("trusted topology peer $PEER is not running")); assert!(step.script.contains("docker logs --tail 200")); + assert!(step.script.contains("public CA is not readable")); + assert!(step.script.contains(ADO_PROXY_PUBLIC_CA_HOST_PATH)); assert_eq!(step.display_name, "Verify trusted topology peers"); } From 4f14cca2366a2ebe36e2a014c9b29efccc97b57f Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 5 Aug 2026 15:44:07 +0100 Subject: [PATCH 34/42] fix(ado-proxy): accept the MCP project-list query shape The credential-isolated runner smoke proved all three wrapped-az reads, then correctly withheld its proof tag because first-party `core_list_projects` was rejected on the boolean `getDefaultTeamImageUrl` query parameter that the MCP always sends. Allow that bounded, non-secret project-list option on the existing core.project-validation-probe operation. Response filtering still removes every project outside the organization-relative authorized scope. Also make the smoke use a writable Azure CLI config directory from its first command, avoiding irrelevant permission retries inside the rootless sandbox. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- scripts/ado-script/src/ado-proxy/catalog.gen.json | 3 ++- scripts/ado-script/src/ado-proxy/policy.test.ts | 11 +++++++++++ src/ado_proxy/catalog.rs | 11 ++++++++++- tests/smoke/ado-proxy.md | 6 +++--- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/scripts/ado-script/src/ado-proxy/catalog.gen.json b/scripts/ado-script/src/ado-proxy/catalog.gen.json index b5112d9d6..db936543a 100644 --- a/scripts/ado-script/src/ado-proxy/catalog.gen.json +++ b/scripts/ado-script/src/ado-proxy/catalog.gen.json @@ -120,7 +120,8 @@ "allowed_query": [ "stateFilter", "$top", - "$skip" + "$skip", + "getDefaultTeamImageUrl" ], "denied_query": [], "max_response_bytes": 10485760 diff --git a/scripts/ado-script/src/ado-proxy/policy.test.ts b/scripts/ado-script/src/ado-proxy/policy.test.ts index cb15c48fd..51a0ffc03 100644 --- a/scripts/ado-script/src/ado-proxy/policy.test.ts +++ b/scripts/ado-script/src/ado-proxy/policy.test.ts @@ -73,6 +73,17 @@ describe("authorize — allowed reads", () => { if (decision.allow) expect(decision.operation.id).toBe("core.project-get"); }); + it("allows the first-party MCP project-list query shape", () => { + const decision = decide( + "GET", + "/contoso/_apis/projects?api-version=7.1&getDefaultTeamImageUrl=true", + ); + expect(decision.allow).toBe(true); + if (decision.allow) { + expect(decision.operation.id).toBe("core.project-validation-probe"); + } + }); + it("accepts the project GUID as well as the name", () => { // `az` substitutes whichever identifier it cached, so both must work — but // only for the pinned project. diff --git a/src/ado_proxy/catalog.rs b/src/ado_proxy/catalog.rs index 4d47a065a..576823dbd 100644 --- a/src/ado_proxy/catalog.rs +++ b/src/ado_proxy/catalog.rs @@ -341,7 +341,16 @@ pub fn operations() -> Vec { "/{org}/_apis/projects", FilterProjectsToCurrent, FilterProjects, - &["stateFilter", "$top", "$skip"] + &[ + "stateFilter", + "$top", + "$skip", + // The first-party Azure DevOps MCP always sends this boolean + // from core_list_projects. It controls inclusion of a + // non-secret project/team image URL; response filtering still + // removes every project outside the authorized scope. + "getDefaultTeamImageUrl", + ] ), get!( "repos.repository-get", diff --git a/tests/smoke/ado-proxy.md b/tests/smoke/ado-proxy.md index bf9224091..3469c1161 100644 --- a/tests/smoke/ado-proxy.md +++ b/tests/smoke/ado-proxy.md @@ -40,7 +40,7 @@ output. The parent smoke orchestrator will fail because the proof tag is absent. proxy: ```bash - az devops project show \ + AZURE_CONFIG_DIR=/tmp/ado-aw-az-config az devops project show \ --organization "$(System.CollectionUri)" \ --project "$(System.TeamProject)" \ --output json | head -40 @@ -49,7 +49,7 @@ output. The parent smoke orchestrator will fail because the proof tag is absent. 2. Prove a GUID-addressed current-project read works through `az rest`: ```bash - az rest \ + AZURE_CONFIG_DIR=/tmp/ado-aw-az-config az rest \ --method get \ --url "$(System.CollectionUri)_apis/projects/$(System.TeamProjectId)?api-version=7.1" \ --output json | head -40 @@ -58,7 +58,7 @@ output. The parent smoke orchestrator will fail because the proof tag is absent. 3. Prove the current repository is readable by repository GUID: ```bash - az rest \ + AZURE_CONFIG_DIR=/tmp/ado-aw-az-config az rest \ --method get \ --url "$(System.CollectionUri)$(System.TeamProject)/_apis/git/repositories/$(Build.Repository.ID)/refs?api-version=7.1&filter=heads" \ --output json | head -40 From 92239f2dd2139f023b799578412c36954ec14bc9 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 5 Aug 2026 15:56:10 +0100 Subject: [PATCH 35/42] fix(ado-proxy): give wrapped az a writable config directory Azure CLI writes extension metadata, command indexes and defaults under its config directory. The rootless AWF agent cannot write the runner user's default ~/.azure, which caused the smoke agent to burn time retrying commands and then improvise `mkdir` calls that Copilot correctly denied because only az/head were allow-listed. Make the generated wrapper supply a stable sandbox-local AZURE_CONFIG_DIR under /tmp, honour an explicit caller override, create it before every invocation and fail with a direct message if it is not writable. The smoke no longer carries a product-specific workaround. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- src/compile/az_wrapper.rs | 26 +++++++++++++++++++++++++- tests/smoke/ado-proxy.md | 6 +++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/compile/az_wrapper.rs b/src/compile/az_wrapper.rs index 43e63241d..f3e2db72e 100644 --- a/src/compile/az_wrapper.rs +++ b/src/compile/az_wrapper.rs @@ -110,6 +110,18 @@ export https_proxy REQUESTS_CA_BUNDLE="{AZ_WRAPPER_CA_PATH}" export REQUESTS_CA_BUNDLE +# Azure CLI writes extension metadata, command indexes and defaults beneath its +# config directory. The rootless AWF agent cannot write the runner user's +# default ~/.azure, so establish a private, writable sandbox-local default. +# Honour an explicit caller override for tests and advanced use. +AZURE_CONFIG_DIR="${{AZURE_CONFIG_DIR:-${{TMPDIR:-/tmp}}/ado-aw-az-config}}" +export AZURE_CONFIG_DIR +mkdir -p "$AZURE_CONFIG_DIR" +if [ ! -w "$AZURE_CONFIG_DIR" ]; then + echo "ado-aw: Azure CLI config directory is not writable: $AZURE_CONFIG_DIR" >&2 + exit 1 +fi + # A non-secret placeholder. `az` requires *some* credential to attempt a call; # the engine strips whatever the client sent and attaches the real bearer only # after a complete allow decision. @@ -175,6 +187,19 @@ mod tests { assert!(!script.contains("/usr/local/share/ca-certificates")); } + #[test] + fn provides_a_writable_azure_cli_config_directory() { + let script = wrapper(); + assert!(script.contains( + "AZURE_CONFIG_DIR=\"${AZURE_CONFIG_DIR:-${TMPDIR:-/tmp}/ado-aw-az-config}\"" + )); + assert!(script.contains("mkdir -p \"$AZURE_CONFIG_DIR\"")); + assert!(script.contains("if [ ! -w \"$AZURE_CONFIG_DIR\" ]")); + // A caller may deliberately isolate invocations further; the wrapper + // supplies a safe default rather than overriding one. + assert!(script.contains("${AZURE_CONFIG_DIR:-")); + } + #[test] fn carries_a_sentinel_rather_than_a_credential() { let script = wrapper(); @@ -282,4 +307,3 @@ mod tests { } } - diff --git a/tests/smoke/ado-proxy.md b/tests/smoke/ado-proxy.md index 3469c1161..bf9224091 100644 --- a/tests/smoke/ado-proxy.md +++ b/tests/smoke/ado-proxy.md @@ -40,7 +40,7 @@ output. The parent smoke orchestrator will fail because the proof tag is absent. proxy: ```bash - AZURE_CONFIG_DIR=/tmp/ado-aw-az-config az devops project show \ + az devops project show \ --organization "$(System.CollectionUri)" \ --project "$(System.TeamProject)" \ --output json | head -40 @@ -49,7 +49,7 @@ output. The parent smoke orchestrator will fail because the proof tag is absent. 2. Prove a GUID-addressed current-project read works through `az rest`: ```bash - AZURE_CONFIG_DIR=/tmp/ado-aw-az-config az rest \ + az rest \ --method get \ --url "$(System.CollectionUri)_apis/projects/$(System.TeamProjectId)?api-version=7.1" \ --output json | head -40 @@ -58,7 +58,7 @@ output. The parent smoke orchestrator will fail because the proof tag is absent. 3. Prove the current repository is readable by repository GUID: ```bash - AZURE_CONFIG_DIR=/tmp/ado-aw-az-config az rest \ + az rest \ --method get \ --url "$(System.CollectionUri)$(System.TeamProject)/_apis/git/repositories/$(Build.Repository.ID)/refs?api-version=7.1&filter=heads" \ --output json | head -40 From 507a7a895b87473d091e353aa15c6e86e1ae04b4 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 5 Aug 2026 15:59:28 +0100 Subject: [PATCH 36/42] test(ado-proxy): exercise runtime denials on the ADO runner Extends the candidate smoke beyond successful reads. Before emitting its proof tag, the agent must observe the exact policy refusal for a POST write, a secret-bearing service-connection route, the real out-of-scope sibling project msazuresphere/4x4, and the ungranted pipelines capability. Using a real existing project makes the scope proof load-bearing: success cannot be explained by Azure DevOps failing to find a fabricated identifier. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- tests/smoke/ado-proxy.md | 46 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/tests/smoke/ado-proxy.md b/tests/smoke/ado-proxy.md index bf9224091..250ebdde8 100644 --- a/tests/smoke/ado-proxy.md +++ b/tests/smoke/ado-proxy.md @@ -64,12 +64,52 @@ output. The parent smoke orchestrator will fail because the proof tag is absent. --output json | head -40 ``` -4. Invoke the Azure DevOps MCP tool `core_list_projects`. Confirm its response +4. Prove write methods are refused before route execution. This command must + fail and its error must contain `ado-proxy: POST is not a read method`: + + ```bash + az rest \ + --method post \ + --url "$(System.CollectionUri)_apis/projects/$(System.TeamProjectId)?api-version=7.1" \ + --body '{}' + ``` + +5. Prove a secret-bearing route family is refused. This command must fail and + its error must contain + `ado-proxy: route family /_apis/serviceendpoint is always denied`: + + ```bash + az rest \ + --method get \ + --url "$(System.CollectionUri)$(System.TeamProject)/_apis/serviceendpoint/endpoints?api-version=7.1" + ``` + +6. Prove a real sibling project is refused. `msazuresphere/4x4` exists, but it + is not in this workflow's scope. This command must fail and its error must + contain `ado-proxy:` and `out-of-scope`: + + ```bash + az rest \ + --method get \ + --url "$(System.CollectionUri)_apis/projects/4x4?api-version=7.1" + ``` + +7. Prove an ungranted capability is refused. The front matter grants only + `core` and `repos`; this command must fail and its error must contain + `ado-proxy:` and `capability-disabled`: + + ```bash + az rest \ + --method get \ + --url "$(System.CollectionUri)$(System.TeamProject)/_apis/pipelines?api-version=7.1" + ``` + +8. Invoke the Azure DevOps MCP tool `core_list_projects`. Confirm its response includes `$(System.TeamProject)`. Use the native MCP tool interface, not `curl`, raw HTTP, or shell. -5. Only after all four reads succeed, invoke the `add-build-tag` safe-output - tool with: +9. Only after all allowed reads succeed and all four denials return the + expected policy reasons, invoke the `add-build-tag` safe-output tool with: - `build_id`: `$(Build.BuildId)` - `tag`: `$(Build.BuildId)` From 881f7c72a369835beddf9ac12f7a72792ef7c9ba Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 5 Aug 2026 20:30:01 +0100 Subject: [PATCH 37/42] fix(ado-proxy): gate proxy and wrapped az on read permission Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- AGENTS.md | 2 +- docs/network.md | 23 +- docs/tools.md | 21 +- src/compile/agentic_pipeline.rs | 54 ++-- src/compile/common.rs | 155 +++++++---- src/compile/extensions/azure_cli.rs | 185 ++++++------- src/compile/extensions/mod.rs | 15 +- src/compile/extensions/tests.rs | 36 ++- tests/compiler_tests.rs | 292 +++++--------------- tests/fixtures/ado-proxy-read-only-agent.md | 13 + tests/fixtures/no-ado-read-agent.md | 8 + 11 files changed, 373 insertions(+), 431 deletions(-) create mode 100644 tests/fixtures/ado-proxy-read-only-agent.md create mode 100644 tests/fixtures/no-ado-read-agent.md diff --git a/AGENTS.md b/AGENTS.md index 380f0c08f..3f3727e2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ │ │ ├── repo.rs # RepoContextContributor — repository identity / remote facts │ │ │ │ ├── schedule.rs # ScheduleContextContributor — scheduled-run context facts │ │ │ │ └── workitem.rs # WorkItemContextContributor — linked work-item context facts -│ │ │ ├── azure_cli.rs # Always-on Azure CLI extension (runtime detection, AWF mounts, az allowlist) +│ │ │ ├── azure_cli.rs # permissions.read-gated Azure CLI extension (runtime detection, wrapper, AWF mounts, az policy prompt) │ │ │ └── tests.rs # Extension integration tests │ │ ├── codemods/ # Front-matter codemods (one file per transformation) │ │ │ ├── mod.rs # Codemod struct, CODEMODS registry, runner diff --git a/docs/network.md b/docs/network.md index fdb9f3281..576b991b3 100644 --- a/docs/network.md +++ b/docs/network.md @@ -49,11 +49,24 @@ The following domains are always allowed via `CORE_ALLOWED_HOSTS` in `allowed_ho | `rt.services.visualstudio.com` | Visual Studio runtime telemetry | | `config.edge.skype.com` | Configuration | -The always-on Azure CLI extension additionally contributes `aka.ms` (Microsoft's link shortener, used by `az` subcommand metadata) to the AWF allowlist. See [Always-on Azure CLI (`az`)](#always-on-azure-cli-az) below. - -## Always-on Azure CLI (`az`) - -Every compiled pipeline emits a small *Detect Azure CLI on host* prepare step that runs early in the Agent job. The always-on Azure CLI extension also adds `aka.ms` to the AWF allowlist (the auth and management hosts it declares are already present in `CORE_ALLOWED_HOSTS` above). This mirrors gh-aw's "assume `gh` is on the runner" model: agents can call `az` from their bash tool without opting in — *when the runner has it*. +When `permissions.read` enables credential-isolated Azure DevOps reads, the +Azure CLI extension additionally contributes `aka.ms` (Microsoft's link +shortener, used by `az` subcommand metadata). See +[Proxy-gated Azure CLI (`az`)](#proxy-gated-azure-cli-az) below. + +## Proxy-gated Azure CLI (`az`) + +`permissions.read` is both the trusted `ado-proxy` token source and the +activation gate for wrapped `az`. With no read permission, the compiler emits +no Azure CLI detection, mount, PATH entry, shell permission, prompt, or proxy +topology. The pinned AWF agent image contains no built-in `az`, so the command +is absent rather than available unproxied. + +When `permissions.read` is present, the compiler emits a small *Detect Azure +CLI on host* prepare step early in the Agent job. If the runner has Azure CLI, +the real binary is mounted only behind the generated wrapper and running +proxy. The extension also adds `aka.ms`; its other declared hosts already +exist in `CORE_ALLOWED_HOSTS`. ### Runtime detection and graceful degradation diff --git a/docs/tools.md b/docs/tools.md index 8f995e2dc..f818c91cb 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -98,19 +98,18 @@ Azure DevOps traffic through the proxy. Catalogued reads (`az devops`, `az repos`, `az pipelines`, `az boards`, and `az rest`) work without signing in; writes and secret-bearing route families fail closed. -## Built-in CLIs - -Two CLI tools are always available to the agent's bash tool without -opting in. This mirrors gh-aw's "the runner has `gh`" assumption: the -host is presumed to have each binary pre-installed. +## Host-provided CLIs ### Azure CLI (`az`) -Every compiled pipeline adds the Azure auth and management hosts -(`login.microsoftonline.com`, `login.windows.net`, -`management.azure.com`, `graph.microsoft.com`, `aka.ms`) to the AWF -allowlist and emits a *Detect Azure CLI on host* prepare step in the -Agent job. The compiler does not install `az`. +Azure CLI is available only when `permissions.read` enables `ado-proxy`. +Without it, the compiler emits no detection, mount, wrapper, PATH entry, shell +permission, or Azure CLI-specific host contribution. + +With `permissions.read`, the compiler adds the relevant hosts and emits a +*Detect Azure CLI on host* prepare step in the Agent job. The compiler does not +install `az`; when the runner provides it, the binary is mounted only behind +the generated wrapper. **Runtime detection + graceful degradation.** The detection step does two things at pipeline time: @@ -132,7 +131,7 @@ the two mounts appear; absent → the line collapses to nothing. No static `--mount` is emitted for `/opt/az` or `/usr/bin/az`, so the pipeline never crashes `docker run` with "bind source path does not exist" on runners without `az`. See -[`docs/network.md`](network.md#always-on-azure-cli-az) for the full +[`docs/network.md`](network.md#proxy-gated-azure-cli-az) for the full design. **Conditional agent prompt advisory.** When (and only when) `az` is diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 271940262..51408ae28 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -1140,12 +1140,12 @@ fn build_agent_job( // Must precede MCPG: the Azure DevOps MCP is redirected at the // engine's container address, and that address does not exist until // the engine is running. - let ado_proxy_enabled = front_matter - .tools - .as_ref() - .is_some_and(|tools| tools.azure_devops.is_some()); + let ado_proxy_enabled = common::ado_proxy_enabled(front_matter); if ado_proxy_enabled { - steps.push(Step::Bash(prepare_ado_proxy_clients_step())); + steps.push(Step::Bash(prepare_ado_proxy_network_step())); + if common::ado_mcp_enabled(front_matter) { + steps.push(Step::Bash(prepare_ado_mcp_step())); + } steps.push(Step::Bash(start_ado_proxy_step(front_matter))); } @@ -4098,24 +4098,8 @@ fn safe_outputs_summary_step(reviewed: &[String]) -> BashStep { .with_condition(Condition::Always) } -/// Prepare the host-side prerequisites for routing the Azure DevOps MCP -/// through the policy engine. -/// -/// Two things the engine cannot do for itself: -/// -/// 1. **A shared network.** The MCP reaches the engine here rather than over -/// AWF's network, which it is not attached to. AWF's `DOCKER-USER` rules are -/// scoped to its own bridge, so they do not filter this one — the MCP can -/// reach the engine, and nothing else. -/// 2. **The MCP package.** It is installed on the runner, which has registry -/// access, and mounted read-only into a container that does not. That keeps -/// the MCP image stock (`node:20-slim`) so nothing new enters the supply -/// chain, and removes `npx`'s start-time registry dependency. -/// -/// The mount point is load-bearing: Node resolves dependencies by walking -/// upward from the importing file, so the tree must land at -/// `/app/node_modules` or the MCP's own imports fail to resolve. -fn prepare_ado_proxy_clients_step() -> BashStep { +/// Prepare the isolated Docker network shared by the proxy and optional MCP. +fn prepare_ado_proxy_network_step() -> BashStep { let script = format!( "set -euo pipefail\n\ \n\ @@ -4131,7 +4115,20 @@ fn prepare_ado_proxy_clients_step() -> BashStep { # onto awf-net, where Squid lives.\n\ if ! docker network inspect {ADO_PROXY_NETWORK_NAME} >/dev/null 2>&1; then\n \ docker network create --internal {ADO_PROXY_NETWORK_NAME}\n\ - fi\n\ + fi\n" + ); + bash("Prepare ado-proxy network", script) +} + +/// Stage the Azure DevOps MCP package only when its tool is enabled. +/// +/// It is installed on the runner, which has registry access, and mounted +/// read-only into a container that does not. The mount point is load-bearing: +/// Node resolves dependencies by walking upward from the importing file, so +/// the tree must land at `/app/node_modules`. +fn prepare_ado_mcp_step() -> BashStep { + let script = format!( + "set -euo pipefail\n\ \n\ # Install the MCP on the runner and stage it for mounting. The\n\ # container it is mounted into can reach nothing but the engine, so it\n\ @@ -4163,7 +4160,7 @@ fn prepare_ado_proxy_clients_step() -> BashStep { fi\n\ echo \"Azure DevOps MCP $MCP_INSTALLED staged at {ADO_MCP_HOST_NODE_MODULES}\"\n" ); - bash("Prepare Azure DevOps MCP and proxy network", script) + bash("Prepare Azure DevOps MCP", script) } /// Remove the network created for the policy engine and its clients. @@ -5764,10 +5761,11 @@ safe-outputs: // The Azure DevOps MCP is redirected at the engine's container // address, which does not exist until the engine is running. Starting // MCPG first would leave the redirect unresolvable. - let script = prepare_ado_proxy_clients_step().script; - assert!(script.contains(&format!( + let network_script = prepare_ado_proxy_network_step().script; + assert!(network_script.contains(&format!( "docker network create --internal {ADO_PROXY_NETWORK_NAME}" ))); + let script = prepare_ado_mcp_step().script; assert!( script.contains(&format!("{ADO_MCP_PACKAGE}@{ADO_MCP_VERSION}")), "the MCP package must be pinned, not floating: {script}" @@ -5803,7 +5801,7 @@ safe-outputs: // `--internal` the MCP would keep a direct route to every Azure DevOps // host the redirect does not override, and the engine would police one // hostname rather than the boundary. - let script = prepare_ado_proxy_clients_step().script; + let script = prepare_ado_proxy_network_step().script; assert!( script.contains("--internal"), "the MCP must not be able to route past the policy engine: {script}" diff --git a/src/compile/common.rs b/src/compile/common.rs index 79865fbc4..a9313f940 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -77,6 +77,32 @@ fn test_validate_permissions_read_policy_ignores_explicitly_disabled_tool() { assert!(!ado_proxy_enabled(&disabled)); } +#[test] +fn test_ado_proxy_activation_follows_permissions_read_not_mcp_tool() { + for (source, expected) in [ + ("---\nname: t\ndescription: x\n---\n", false), + ( + "---\nname: t\ndescription: x\ntools:\n azure-devops: true\n---\n", + false, + ), + ( + "---\nname: t\ndescription: x\npermissions:\n read: my-read-sc\n---\n", + true, + ), + ( + "---\nname: t\ndescription: x\ntools:\n azure-devops: false\npermissions:\n read:\n service-connection: my-read-sc\n capabilities: [core]\n---\n", + true, + ), + ] { + let (front_matter, _) = parse_markdown(source).unwrap(); + assert_eq!( + ado_proxy_enabled(&front_matter), + expected, + "unexpected activation for:\n{source}" + ); + } +} + /// Returns the directory in which the atomic tempfile should be created for a /// write to `path`. The tempfile must live on the same filesystem as `path` /// so that the final `persist()` rename is atomic (EXDEV guard). @@ -599,12 +625,7 @@ pub fn validate_proxied_timeout(front_matter: &FrontMatter, timeout_minutes: u32 if timeout_minutes <= MAX_PROXIED_TIMEOUT_MINUTES { return Ok(()); } - let uses_proxy = front_matter - .permissions - .as_ref() - .and_then(|permissions| permissions.read.as_ref()) - .and_then(crate::compile::types::ReadPermissionConfig::options) - .is_some(); + let uses_proxy = ado_proxy_enabled(front_matter); if !uses_proxy { return Ok(()); } @@ -625,7 +646,7 @@ pub fn validate_proxied_timeout(front_matter: &FrontMatter, timeout_minutes: u32 /// the compile path so a widening produced by omission — such as naming an /// organization with no projects — fails before any pipeline is emitted. pub fn validate_permissions_read_policy(front_matter: &FrontMatter) -> Result<()> { - if ado_proxy_enabled(front_matter) + if ado_mcp_enabled(front_matter) && front_matter .permissions .as_ref() @@ -1798,12 +1819,24 @@ pub fn resolve_ado_organization_bash(indent: &str) -> String { /// Whether this workflow routes Azure DevOps access through the policy engine. /// -/// Enabling `tools.azure-devops` is what pulls in the engine: the MCP is -/// redirected at it and the `az` wrapper points at it. Both the pipeline -/// builder and the Azure CLI extension need this answer and must not disagree — -/// a mismatch would either install a wrapper pointing at an engine that was -/// never started, or start an engine that nothing routes through. +/// `permissions.read` is the activation switch and trusted token source. The +/// pipeline builder and Azure CLI extension must not disagree: a mismatch +/// would either install a wrapper pointing at an engine that was never started, +/// or expose host `az` without the policy boundary. pub fn ado_proxy_enabled(front_matter: &FrontMatter) -> bool { + front_matter + .permissions + .as_ref() + .and_then(|permissions| permissions.read.as_ref()) + .is_some() +} + +/// Whether the first-party Azure DevOps MCP client is enabled. +/// +/// This is deliberately narrower than [`ado_proxy_enabled`]: read permission +/// activates the proxy and wrapped `az`, while `tools.azure-devops` alone +/// controls MCP package staging and child configuration. +pub fn ado_mcp_enabled(front_matter: &FrontMatter) -> bool { front_matter .tools .as_ref() @@ -4185,20 +4218,25 @@ mod tests { }); let params = engine_args_for(&fm).unwrap(); // User-disabled bash must not produce a general bash allow-tool - // (shell(:*) / shell(*) / shell(bash)). Always-on extensions - // (e.g. Azure CLI) legitimately inject their own narrow - // shell() entries via `required_bash_commands()`; those are - // expected and should not regress this test. + // (shell(:*) / shell(*) / shell(bash)). assert!(!params.contains("shell(:*)")); assert!(!params.contains("shell(*)")); assert!(!params.contains("shell(bash)")); - // Sanity-check: the always-on Azure CLI extension still injects - // its bash requirement even when user bash is disabled — agents - // must be able to call `az` regardless of the user's `bash:` - // narrowing decisions. + assert!( + !params.contains("shell(az)"), + "without permissions.read, Azure CLI must not be exposed: {params}" + ); + + fm.permissions = Some(crate::compile::types::PermissionsConfig { + read: Some(crate::compile::types::ReadPermissionConfig::ServiceConnection( + crate::secure::ServiceConnection::parse("read-sc").unwrap(), + )), + write: None, + }); + let params = engine_args_for(&fm).unwrap(); assert!( params.contains("shell(az)"), - "always-on Azure CLI extension should still inject shell(az): {params}" + "permissions.read must add the wrapped az command even when the user's bash list is empty" ); } @@ -5959,38 +5997,38 @@ safe-outputs: /// 502s partway through a run. #[test] fn proxied_timeout_is_bounded_by_the_token_lifetime() { - let proxied = "---\nname: t\ndescription: d\npermissions:\n read:\n service-connection: sc\n---\n"; - let (fm, _) = parse_markdown(proxied).unwrap(); + for proxied in [ + "---\nname: t\ndescription: d\npermissions:\n read: sc\n---\n", + "---\nname: t\ndescription: d\npermissions:\n read:\n service-connection: sc\n---\n", + ] { + let (fm, _) = parse_markdown(proxied).unwrap(); - assert!(validate_proxied_timeout(&fm, MAX_PROXIED_TIMEOUT_MINUTES).is_ok()); + assert!(validate_proxied_timeout(&fm, MAX_PROXIED_TIMEOUT_MINUTES).is_ok()); - let error = validate_proxied_timeout(&fm, MAX_PROXIED_TIMEOUT_MINUTES + 1) - .unwrap_err() - .to_string(); - assert!( - error.contains("cannot renew"), - "the message must say why, not just that it is too long: {error}" - ); - assert!( - error.contains(&MAX_PROXIED_TIMEOUT_MINUTES.to_string()), - "the message must name the limit: {error}" - ); + let error = validate_proxied_timeout(&fm, MAX_PROXIED_TIMEOUT_MINUTES + 1) + .unwrap_err() + .to_string(); + assert!( + error.contains("cannot renew"), + "the message must say why, not just that it is too long: {error}" + ); + assert!( + error.contains(&MAX_PROXIED_TIMEOUT_MINUTES.to_string()), + "the message must name the limit: {error}" + ); + } } /// Workflows that do not use the proxy hold no Azure DevOps credential in /// the agent, so there is nothing to expire and no reason to bound them. #[test] fn unproxied_timeout_is_not_bounded() { - for source in [ - "---\nname: t\ndescription: d\n---\n", - "---\nname: t\ndescription: d\npermissions:\n read: my-read-sc\n---\n", - ] { - let (fm, _) = parse_markdown(source).unwrap(); - assert!( - validate_proxied_timeout(&fm, MAX_PROXIED_TIMEOUT_MINUTES * 10).is_ok(), - "a workflow without the proxy must not be limited: {source}" - ); - } + let source = "---\nname: t\ndescription: d\n---\n"; + let (fm, _) = parse_markdown(source).unwrap(); + assert!( + validate_proxied_timeout(&fm, MAX_PROXIED_TIMEOUT_MINUTES * 10).is_ok(), + "a workflow without the proxy must not be limited: {source}" + ); } #[test] @@ -6473,22 +6511,29 @@ safe-outputs: // ─── generate_awf_mounts ────────────────────────────────────────────── #[test] - fn test_generate_awf_mounts_always_on_az_cli_baseline() { - // Even with a minimal front matter, the always-on Azure CLI - // extension contributes a `$(AW_AZ_MOUNTS) \` injection line - // (no static mounts — those are runtime-detected by the - // AzureCli prepare step which sets the pipeline variable). - // The "no mounts" name is historical; this test now verifies - // the always-on baseline. + fn test_generate_awf_mounts_omits_az_without_read_permission() { let fm = minimal_front_matter(); let exts = crate::compile::extensions::collect_extensions(&fm); - let _ctx = crate::compile::extensions::CompileContext::for_test(&fm); + let declarations = extension_declarations(&exts, &fm); + let result = generate_awf_mounts(&exts, &declarations); + assert!( + !result.contains("AW_AZ_MOUNTS"), + "without permissions.read, no Azure CLI runtime mount hook may be emitted: {result}" + ); + } + + #[test] + fn test_generate_awf_mounts_includes_runtime_az_hook_with_read_permission() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: d\npermissions:\n read: read-sc\n---\n", + ) + .unwrap(); + let exts = crate::compile::extensions::collect_extensions(&fm); let declarations = extension_declarations(&exts, &fm); let result = generate_awf_mounts(&exts, &declarations); assert!( result.contains("$(AW_AZ_MOUNTS) \\"), - "always-on Azure CLI injection line $(AW_AZ_MOUNTS) \\ should be present \ - (so the AzureCli prepare step's pipeline variable expands into runtime mounts): {result}" + "permissions.read must emit the conditional host-az mount hook: {result}" ); assert!( !result.contains(r#"--mount "/opt/az:/opt/az:ro""#), diff --git a/src/compile/extensions/azure_cli.rs b/src/compile/extensions/azure_cli.rs index b72836112..c04570c03 100644 --- a/src/compile/extensions/azure_cli.rs +++ b/src/compile/extensions/azure_cli.rs @@ -7,14 +7,13 @@ use crate::compile::common::{ use crate::compile::ir::condition::{Condition, Expr}; use crate::compile::ir::step::{BashStep, Step}; -// ─── Azure CLI (always-on, install-free, gh-aw parity) ──────────────── +// ─── Azure CLI (permissions.read-gated, install-free) ──────────────── /// Azure CLI extension. /// -/// Always-on internal extension that exposes the host's pre-installed -/// `az` binary to the agent inside the AWF Docker container (when -/// present), and adds the necessary Azure auth/management hosts to the -/// AWF allowlist so `az` calls aren't blocked by the L7 proxy. +/// Internal extension enabled only when `permissions.read` supplies the +/// trusted ado-proxy token source. It exposes the host's pre-installed `az` +/// binary only behind the generated wrapper and running proxy. /// /// **Install posture.** Mirrors gh-aw's "assume the CLI is on the /// runner" model: this extension does NOT install `az`. Microsoft-hosted @@ -51,16 +50,10 @@ use crate::compile::ir::step::{BashStep, Step}; /// either the two `--mount` args or nothing — bash word-splits on the /// expansion either way. /// -/// **Allowlist + bash command.** The 5 Azure auth/management hosts and -/// the `az` bash command name are added unconditionally — they are -/// inert when the runtime detection skips the mount (allowing hosts you -/// can't reach and a command that doesn't resolve is harmless and -/// keeps the compiled YAML deterministic across runner types). -/// -/// **Auth.** This extension only exposes the binary. It does not inject an -/// Azure or Azure DevOps credential into the agent sandbox. -/// `permissions.read` authenticates the optional first-party Azure DevOps MCP -/// backend; it does not populate `AZURE_DEVOPS_EXT_PAT` for direct CLI use. +/// Without `permissions.read`, the extension is not collected: no detection, +/// mount, PATH entry, bash permission, Azure host contribution, or prompt is +/// emitted. The pinned AWF agent image contains no built-in `az`, so raw CLI +/// access is impossible in that state. pub struct AzureCliExtension; impl CompilerExtension for AzureCliExtension { @@ -81,23 +74,23 @@ impl CompilerExtension for AzureCliExtension { /// the empty-string literal — same wire shape as today's /// `condition: ne(variables['AW_AZ_MOUNTS'], '')`. fn declarations(&self, ctx: &CompileContext) -> anyhow::Result { - let proxied = crate::compile::common::ado_proxy_enabled(ctx.front_matter); + debug_assert!(crate::compile::common::ado_proxy_enabled( + ctx.front_matter + )); let capabilities = crate::compile::common::ado_proxy_capabilities(ctx.front_matter); let mut agent_prepare_steps = vec![Step::Bash(detection_bash_step())]; - if proxied { - // Installed before the prompt is appended so the advisory and the - // wrapper cannot describe different worlds. - agent_prepare_steps.push(Step::Bash(install_az_wrapper_step(&capabilities))); - // This advisory is independent of `az` detection: the same policy - // governs MCP reads, and the agent must understand effective - // front-matter scope even on a runner without Azure CLI. - agent_prepare_steps.push(Step::Bash(proxy_policy_prompt_step( - ctx.front_matter, - &capabilities, - ))); - } - agent_prepare_steps.push(Step::Bash(prompt_append_bash_step(proxied, &capabilities))); + // Installed before the prompt is appended so the advisory and the + // wrapper cannot describe different worlds. + agent_prepare_steps.push(Step::Bash(install_az_wrapper_step(&capabilities))); + // This advisory is independent of `az` detection: the same policy + // governs MCP reads, and the agent must understand effective + // front-matter scope even on a runner without Azure CLI. + agent_prepare_steps.push(Step::Bash(proxy_policy_prompt_step( + ctx.front_matter, + &capabilities, + ))); + agent_prepare_steps.push(Step::Bash(prompt_append_bash_step(&capabilities))); Ok(Declarations { network_hosts: vec![ @@ -118,11 +111,7 @@ impl CompilerExtension for AzureCliExtension { // /usr/local/bin is not the chroot's, and only PATH order decides // which binary the agent actually invokes. AWF installs its own `gh` // wrapper the same way. - awf_path_prepends: if proxied { - vec![AZ_WRAPPER_DIR.to_string()] - } else { - Vec::new() - }, + awf_path_prepends: vec![AZ_WRAPPER_DIR.to_string()], ..Declarations::default() }) } @@ -270,15 +259,14 @@ echo \"ado-proxy policy prompt appended\"\n" /// will not try it, and one told it has access it lacks will retry a failing /// call or invent a workaround. The unproxied text deliberately claims nothing /// beyond "not pre-authenticated" — an earlier revision overclaimed here. -fn prompt_append_bash_step(proxied: bool, capabilities: &[Capability]) -> BashStep { - let body = if proxied { - let groups = crate::compile::common::az_allowed_groups(capabilities); - let group_list = groups - .iter() - .map(|g| format!("`az {g}`")) - .collect::>() - .join(", "); - format!( +fn prompt_append_bash_step(capabilities: &[Capability]) -> BashStep { + let groups = crate::compile::common::az_allowed_groups(capabilities); + let group_list = groups + .iter() + .map(|g| format!("`az {g}`")) + .collect::>() + .join(", "); + let body = format!( "\n\ ---\n\ \n\ @@ -292,22 +280,7 @@ The Azure CLI is available and **pre-configured for Azure DevOps reads**. You do Requests outside that boundary are refused by a policy proxy, not by a misconfiguration — retrying, changing the URL, or trying to authenticate will not help. To *change* anything, emit a safe output instead; that is the supported path for writes.\n\ \n\ If a read you need is refused, file a `missing-tool` safe output naming `azure-cli` and the exact command, so the operator can extend the catalog rather than leaving you blocked.\n" - ) - } else { - "\n\ ----\n\ -\n\ -## Azure CLI (`az`)\n\ -\n\ -The Azure CLI is available inside this sandbox at `/usr/bin/az`, but ado-aw does not inject an Azure or Azure DevOps credential into the sandbox:\n\ -\n\ -- **Azure DevOps** \u{2014} `az devops`, `az pipelines`, `az repos`, and `az boards` are not pre-authenticated. When configured, use the `azure-devops` MCP tools for authenticated ADO reads.\n\ -- **Azure Resource Manager and Microsoft Graph** \u{2014} `az resource`, `az account`, `az group`, `az ad`, and authenticated `az rest` calls are not configured for agent use.\n\ -- Do not sign in or place Azure credentials in the sandbox. Request a supported tool instead.\n\ -\n\ -If a command you need isn't covered above, file a `missing-tool` safe output naming `azure-cli` so the operator can extend coverage rather than blocking on it silently.\n" - .to_string() - }; + ); let script = format!( "cat >> \"/tmp/awf-tools/agent-prompt.md\" << 'AZURE_CLI_PROMPT_EOF'\n\ @@ -329,14 +302,20 @@ mod tests { use crate::compile::types::FrontMatter; fn fm() -> FrontMatter { + serde_yaml::from_str( + "name: t\ndescription: x\npermissions:\n read: my-read-sc\n", + ) + .expect("front matter parses") + } + + fn fm_unproxied() -> FrontMatter { serde_yaml::from_str("name: t\ndescription: x\n").expect("front matter parses") } - /// Front matter that enables the Azure DevOps tool, which is what pulls in - /// the policy engine and therefore the wrapper. + /// `permissions.read` pulls in the policy engine and therefore the wrapper; + /// the MCP tool is independent. fn fm_proxied() -> FrontMatter { - serde_yaml::from_str("name: t\ndescription: x\ntools:\n azure-devops:\n org: myorg\n") - .expect("front matter parses") + fm() } fn wrapper_step(front_matter: &FrontMatter) -> Option { @@ -370,8 +349,16 @@ mod tests { #[test] fn the_wrapper_is_installed_only_when_traffic_is_policed() { // Without the policy engine there is nothing to redirect to, and - // shadowing `az` would break it rather than contain it. - assert!(wrapper_step(&fm()).is_none()); + // the extension is not collected at all. + let unproxied = fm_unproxied(); + assert!( + !crate::compile::extensions::collect_extensions(&unproxied) + .iter() + .any(|extension| matches!( + extension, + crate::compile::extensions::Extension::AzureCli(_) + )) + ); assert!(wrapper_step(&fm_proxied()).is_some()); } @@ -379,16 +366,6 @@ mod tests { fn the_wrapper_directory_shadows_the_real_az() { // The file alone is not enough: the agent runs in a chroot, so only // PATH order decides which binary it actually invokes. - let plain = fm(); - let ctx_plain = CompileContext::for_test(&plain); - assert!( - AzureCliExtension - .declarations(&ctx_plain) - .unwrap() - .awf_path_prepends - .is_empty() - ); - let proxied = fm_proxied(); let ctx = CompileContext::for_test(&proxied); assert_eq!( @@ -534,15 +511,15 @@ repos: let fm = fm(); let ctx = CompileContext::for_test(&fm); let steps = agent_prepare_steps(&ext, &ctx); - // Two prepare steps: [0] detection (always runs), [1] conditional - // prompt-append (skipped when AW_AZ_MOUNTS is empty). The + // Four prepare steps: detection, wrapper install, policy prompt and + // conditional Azure CLI prompt. The // detection step MUST stay at index 0 — it is what sets the // pipeline variable that the prompt-append step's // `condition:` reads. assert_eq!( steps.len(), - 2, - "expected two prepare steps (detection, conditional prompt-append), got: {steps:?}" + 4, + "expected detection, wrapper, policy prompt and CLI prompt, got: {steps:?}" ); let step = bash_step(&steps[0]); // Detection must check both the launcher shim and the venv @@ -682,7 +659,7 @@ repos: ); } - // ── Conditional prompt-append step (step index 1) ────────────────────── + // ── Conditional Azure CLI prompt step ────────────────────────────────── #[test] fn test_azure_cli_prompt_append_step_is_conditional() { @@ -695,7 +672,11 @@ repos: let fm = fm(); let ctx = CompileContext::for_test(&fm); let steps = agent_prepare_steps(&ext, &ctx); - let append = bash_step(&steps[1]); + let append = steps + .iter() + .map(bash_step) + .find(|step| step.display_name == "Append Azure CLI prompt") + .expect("Azure CLI prompt step"); assert!(matches!( append.condition, Some(Condition::Ne( @@ -714,7 +695,11 @@ repos: let fm = fm(); let ctx = CompileContext::for_test(&fm); let steps = agent_prepare_steps(&ext, &ctx); - let append = bash_step(&steps[1]); + let append = steps + .iter() + .map(bash_step) + .find(|step| step.display_name == "Append Azure CLI prompt") + .expect("Azure CLI prompt step"); assert!( append .script @@ -734,14 +719,17 @@ repos: let fm = fm(); let ctx = CompileContext::for_test(&fm); let steps = agent_prepare_steps(&ext, &ctx); - let append = bash_step(&steps[1]); + let append = steps + .iter() + .map(bash_step) + .find(|step| step.display_name == "Append Azure CLI prompt") + .expect("Azure CLI prompt step"); for anchor in [ "Azure CLI", - "/usr/bin/az", "az devops", - "not pre-authenticated", - "azure-devops", - "Do not sign in", + "pre-configured for Azure DevOps reads", + "policy proxy", + "safe output", "missing-tool", ] { assert!( @@ -765,7 +753,11 @@ repos: let fm = fm(); let ctx = CompileContext::for_test(&fm); let steps = agent_prepare_steps(&ext, &ctx); - let append = bash_step(&steps[1]); + let append = steps + .iter() + .map(bash_step) + .find(|step| step.display_name == "Append Azure CLI prompt") + .expect("Azure CLI prompt step"); assert!( append.script.contains("<< 'AZURE_CLI_PROMPT_EOF'"), "prompt-append heredoc delimiter must be single-quoted to \ @@ -786,7 +778,11 @@ repos: let fm = fm(); let ctx = CompileContext::for_test(&fm); let steps = agent_prepare_steps(&ext, &ctx); - let append = bash_step(&steps[1]); + let append = steps + .iter() + .map(bash_step) + .find(|step| step.display_name == "Append Azure CLI prompt") + .expect("Azure CLI prompt step"); assert_eq!(append.display_name, "Append Azure CLI prompt"); } @@ -813,15 +809,14 @@ repos: } #[test] - fn test_azure_cli_no_path_prepends() { - // Sanity check that the install-free posture isn't accidentally - // regressed by a future edit that adds a PATH munge. + fn test_azure_cli_prepends_the_wrapper_directory() { + // The wrapper must shadow the mounted real binary. let ext = AzureCliExtension; let fm = fm(); let ctx = CompileContext::for_test(&fm); - assert!( - ext.declarations(&ctx).unwrap().awf_path_prepends.is_empty(), - "must not prepend any PATH entry — /usr/bin is already on PATH inside AWF" + assert_eq!( + ext.declarations(&ctx).unwrap().awf_path_prepends, + vec![AZ_WRAPPER_DIR.to_string()] ); } } diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 534835b2b..6116a107a 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -813,13 +813,18 @@ pub fn collect_extensions(front_matter: &FrontMatter) -> Vec { front_matter.execution_context.clone().unwrap_or_default(), front_matter, )), - // Always-on Azure CLI. Tool phase — mounts host /opt/az and - // /usr/bin/az into AWF and adds Azure auth hosts to the - // allowlist so the agent can call `az`. No install step is - // emitted: host pre-install is assumed (gh-aw parity). - Extension::AzureCli(AzureCliExtension), ]; + // `permissions.read` is both the trusted token source and the activation + // gate for credential-isolated Azure DevOps access. Host `az` must never be + // mounted into the sandbox without the proxy and generated wrapper in the + // path. The pinned AWF agent image contains no built-in `az`, so omitting + // this extension makes the command unavailable rather than exposing an + // unproxied fallback. + if super::common::ado_proxy_enabled(front_matter) { + extensions.push(Extension::AzureCli(AzureCliExtension)); + } + // ── Runtimes (ExtensionPhase::Runtime) ── if let Some(lean) = front_matter.runtimes.as_ref().and_then(|r| r.lean.as_ref()) && lean.is_enabled() diff --git a/src/compile/extensions/tests.rs b/src/compile/extensions/tests.rs index d4b6a1cbe..cced85ab2 100644 --- a/src/compile/extensions/tests.rs +++ b/src/compile/extensions/tests.rs @@ -100,14 +100,15 @@ fn test_awf_mount_serde_roundtrip() { fn test_collect_extensions_empty_front_matter() { let fm = minimal_front_matter(); let exts = collect_extensions(&fm); - // Always-on: ado-aw-marker + ado-script + GitHub + SafeOutputs + ExecContext + Azure CLI - assert_eq!(exts.len(), 6); + // Always-on: ado-aw-marker + ado-script + GitHub + SafeOutputs + ExecContext. + // Azure CLI is present only when permissions.read activates ado-proxy. + assert_eq!(exts.len(), 5); assert!(exts.iter().any(|e| e.name() == "ado-aw-marker")); assert!(exts.iter().any(|e| e.name() == "ado-script")); assert!(exts.iter().any(|e| e.name() == "GitHub")); assert!(exts.iter().any(|e| e.name() == "SafeOutputs")); assert!(exts.iter().any(|e| e.name() == "Execution Context")); - assert!(exts.iter().any(|e| e.name() == "Azure CLI")); + assert!(!exts.iter().any(|e| e.name() == "Azure CLI")); } #[test] @@ -116,7 +117,7 @@ fn test_collect_extensions_lean_enabled() { parse_markdown("---\nname: test\ndescription: test\nruntimes:\n lean: true\n---\n") .unwrap(); let exts = collect_extensions(&fm); - assert_eq!(exts.len(), 7); // always-on (6) + Lean + assert_eq!(exts.len(), 6); // always-on (5) + Lean assert_eq!(exts[0].name(), "ado-script"); // System phase sorts first assert_eq!(exts[1].name(), "Lean 4"); // Runtime phase follows System } @@ -127,7 +128,7 @@ fn test_collect_extensions_lean_disabled() { parse_markdown("---\nname: test\ndescription: test\nruntimes:\n lean: false\n---\n") .unwrap(); let exts = collect_extensions(&fm); - assert_eq!(exts.len(), 6); // Just always-on + assert_eq!(exts.len(), 5); // Just always-on } #[test] @@ -136,8 +137,21 @@ fn test_collect_extensions_azure_devops_enabled() { parse_markdown("---\nname: test\ndescription: test\ntools:\n azure-devops: true\n---\n") .unwrap(); let exts = collect_extensions(&fm); - assert_eq!(exts.len(), 7); // always-on (6) + AzureDevOps + assert_eq!(exts.len(), 6); // always-on (5) + AzureDevOps; no read => no Azure CLI assert!(exts.iter().any(|e| e.name() == "Azure DevOps MCP")); + assert!(!exts.iter().any(|e| e.name() == "Azure CLI")); +} + +#[test] +fn test_collect_extensions_read_permission_enables_azure_cli_without_mcp() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\npermissions:\n read: my-read-sc\n---\n", + ) + .unwrap(); + let exts = collect_extensions(&fm); + assert_eq!(exts.len(), 6); // always-on (5) + Azure CLI + assert!(exts.iter().any(|e| e.name() == "Azure CLI")); + assert!(!exts.iter().any(|e| e.name() == "Azure DevOps MCP")); } #[test] @@ -146,18 +160,18 @@ fn test_collect_extensions_cache_memory_enabled() { parse_markdown("---\nname: test\ndescription: test\ntools:\n cache-memory: true\n---\n") .unwrap(); let exts = collect_extensions(&fm); - assert_eq!(exts.len(), 7); // always-on (6) + CacheMemory + assert_eq!(exts.len(), 6); // always-on (5) + CacheMemory assert!(exts.iter().any(|e| e.name() == "Cache Memory")); } #[test] fn test_collect_extensions_all_enabled() { let (fm, _) = parse_markdown( - "---\nname: test\ndescription: test\nruntimes:\n lean: true\ntools:\n azure-devops: true\n cache-memory: true\n---\n", + "---\nname: test\ndescription: test\nruntimes:\n lean: true\ntools:\n azure-devops: true\n cache-memory: true\npermissions:\n read: my-read-sc\n---\n", ) .unwrap(); let exts = collect_extensions(&fm); - assert_eq!(exts.len(), 9); // always-on (6) + Lean + AzureDevOps + CacheMemory + assert_eq!(exts.len(), 9); // always-on (5) + Lean + AzureDevOps + CacheMemory + Azure CLI assert_eq!(exts[0].name(), "ado-script"); // System phase first assert_eq!(exts[1].name(), "Lean 4"); // Runtime phase next // All trailing extensions are Tool phase @@ -170,11 +184,11 @@ fn test_collect_extensions_runtimes_always_before_tools() { // System-phase extensions appear first, then Runtime, then Tool — // regardless of front matter field order. let (fm, _) = parse_markdown( - "---\nname: test\ndescription: test\ntools:\n azure-devops: true\n cache-memory: true\nruntimes:\n lean: true\n---\n", + "---\nname: test\ndescription: test\ntools:\n azure-devops: true\n cache-memory: true\npermissions:\n read: my-read-sc\nruntimes:\n lean: true\n---\n", ) .unwrap(); let exts = collect_extensions(&fm); - assert_eq!(exts.len(), 9); // always-on (6) + Lean + AzureDevOps + CacheMemory + assert_eq!(exts.len(), 9); // always-on (5) + Lean + AzureDevOps + CacheMemory + Azure CLI // System sorts first assert_eq!(exts[0].phase(), ExtensionPhase::System); diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index eb5687a65..fa3c72afd 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1950,6 +1950,78 @@ Call the noop tool exactly once. // ==================== Azure DevOps MCP Integration Tests ==================== +fn compile_fixture_text(fixture_name: &str) -> String { + let temp_dir = tempfile::tempdir().expect("Failed to create temp directory"); + let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(fixture_name); + let output_path = temp_dir.path().join(format!("{fixture_name}.lock.yml")); + + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args([ + "compile", + fixture_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run compiler"); + assert!( + output.status.success(), + "Compiler failed for {fixture_name}: {}", + String::from_utf8_lossy(&output.stderr) + ); + fs::read_to_string(output_path).expect("Should read compiled output") +} + +#[test] +fn permissions_read_enables_proxy_and_wrapped_az_without_mcp() { + let compiled = compile_fixture_text("ado-proxy-read-only-agent.md"); + + for required in [ + "displayName: Start ado-proxy policy engine", + "displayName: Verify trusted topology peers", + "displayName: Install az wrapper (ado-proxy)", + "displayName: Detect Azure CLI on host (for AWF mount)", + "--topology-attach \"awmg-ado-proxy\"", + "--allow-tool \"shell(az)\"", + ] { + assert!( + compiled.contains(required), + "permissions.read must enable {required}" + ); + } + assert!( + !compiled.contains("/app/node_modules/@azure-devops/mcp/dist/index.js"), + "tools.azure-devops: false must not add the MCP child" + ); + assert!( + !compiled.contains("@azure-devops/mcp@"), + "tools.azure-devops: false must not download or stage the MCP package" + ); +} + +#[test] +fn no_read_permission_exposes_neither_proxy_nor_az() { + let compiled = compile_fixture_text("no-ado-read-agent.md"); + + for forbidden in [ + "ado-proxy policy engine", + "awmg-ado-proxy", + "Install az wrapper", + "Detect Azure CLI on host", + "AW_AZ_MOUNTS", + "shell(az)", + ] { + assert!( + !compiled.contains(forbidden), + "no permissions.read must not expose {forbidden}" + ); + } +} + #[test] fn test_fixture_azure_devops_mcp_requires_read_permission() { let temp_dir = tempfile::tempdir().expect("Failed to create temp directory"); @@ -5932,164 +6004,6 @@ fn test_agent_job_steps_do_not_map_system_access_token() { } } -/// Always-on Azure CLI extension: every compiled pipeline must include a -/// host-detection prepare step that conditionally sets the `AW_AZ_MOUNTS` -/// pipeline variable, and the AWF invocation must reference that -/// variable so the mounts are added at pipeline time only when az is -/// present on the runner. Also asserts that Azure auth hosts are in the -/// allow-list and guards against accidental re-introduction of an -/// install step. -#[test] -fn test_default_pipeline_mounts_az_and_allows_azure_hosts() { - let compiled = compile_fixture("minimal-agent.md"); - assert_valid_yaml(&compiled, "minimal-agent.md"); - - // (1) The detection prepare step must be present. It is the only - // mechanism by which az gets mounted into AWF, so its presence is - // load-bearing for the "always-on az" promise. The displayName is - // also part of the compiled YAML and is what operators see in the - // ADO log; if it changes the documentation in docs/network.md and - // docs/tools.md should be updated too. - assert!( - compiled.contains("displayName: Detect Azure CLI on host (for AWF mount)"), - "compiled YAML must contain the Azure CLI detection prepare step. \ - Compiled:\n{compiled}" - ); - assert!( - compiled.contains("[ -f /usr/bin/az ]"), - "detection step must test for /usr/bin/az. Compiled:\n{compiled}" - ); - assert!( - compiled.contains("##vso[task.setvariable variable=AW_AZ_MOUNTS]"), - "detection step must set the AW_AZ_MOUNTS pipeline variable. \ - Compiled:\n{compiled}" - ); - - // (1a) Regression guard: `setvariable` for AW_AZ_MOUNTS must appear - // TWICE — once per branch of the if/else. If the missing-az branch - // skips the setvariable, ADO leaves the literal `$(AW_AZ_MOUNTS)` - // in the AWF bash step, where bash interprets it as a `$(...)` - // command substitution, attempts to run a program named - // `AW_AZ_MOUNTS`, gets exit 127, and `set -e` kills the pipeline — - // the exact failure mode this PR set out to prevent on runners - // without azure-cli installed. - let setvar_count = compiled - .matches("##vso[task.setvariable variable=AW_AZ_MOUNTS]") - .count(); - assert_eq!( - setvar_count, 2, - "AW_AZ_MOUNTS must be set in BOTH branches of the detection step (got {setvar_count} \ - occurrences); leaving it unset in the missing-az branch breaks `set -e` in the \ - AWF invocation. See AzureCliExtension::prepare_steps for the rationale." - ); - - // (1b) Conditional prompt-append step: when az is detected, the - // agent prompt receives an Azure CLI advisory section so the - // agent knows az is on PATH and that no Azure/ADO credential is - // injected. The step is gated by `condition: ne(variables['AW_AZ_MOUNTS'], '')` - // so agents on runners WITHOUT az never see the advisory and - // never try to call az. - assert!( - compiled.contains("displayName: Append Azure CLI prompt"), - "compiled YAML must contain the 'Append Azure CLI prompt' step \ - emitted by AzureCliExtension::prepare_steps. Compiled:\n{compiled}" - ); - assert!( - compiled.contains("condition: ne(variables['AW_AZ_MOUNTS'], '')"), - "the Azure CLI prompt-append step must carry a condition: \ - ne(variables['AW_AZ_MOUNTS'], '') so it is skipped when az \ - is not detected. Compiled:\n{compiled}" - ); - // Proximity check — the condition: must live on the SAME step as - // the displayName, otherwise we may have accidentally gated the - // wrong step. Find the displayName index, then check the next ~200 - // chars for the condition line. - let display_idx = compiled - .find("displayName: Append Azure CLI prompt") - .expect("displayName already asserted to be present"); - let window_end = (display_idx + 300).min(compiled.len()); - let window = &compiled[display_idx..window_end]; - assert!( - window.contains("condition: ne(variables['AW_AZ_MOUNTS'], '')"), - "the condition: line must appear in the same step block as the \ - 'Append Azure CLI prompt' displayName (looked at the 300 \ - chars after the displayName). Window:\n{window}" - ); - // Anchor strings: lock the load-bearing parts of the advisory. - for anchor in [ - "/usr/bin/az", - "az devops", - "not pre-authenticated", - "azure-devops", - "Do not sign in", - "missing-tool", - ] { - assert!( - compiled.contains(anchor), - "compiled YAML must contain advisory anchor `{anchor}`. \ - Compiled:\n{compiled}" - ); - } - - // (2) The AWF invocation must reference $(AW_AZ_MOUNTS) so the - // pipeline-variable value (the two --mount args, or empty) is - // word-split into the docker run command at runtime. Unquoted on - // purpose — see the safety note in `generate_awf_mounts`. - assert!( - compiled.contains("$(AW_AZ_MOUNTS) \\"), - "AWF invocation must include a `$(AW_AZ_MOUNTS) \\` line so the \ - pipeline variable expands into --mount args at runtime. \ - Compiled:\n{compiled}" - ); - - // (3) Critical guard: we must NOT emit static --mount args for az - // paths, because that would crash `docker run` on runners without - // azure-cli installed (bind source path does not exist). All az - // mounting must go through the runtime-detected pipeline variable. - assert!( - !compiled.contains(r#"--mount "/opt/az:/opt/az:ro""#), - "compiled YAML must NOT contain a static --mount for /opt/az — \ - that would crash `docker run` on runners without azure-cli. \ - Mounts must be contributed via the AW_AZ_MOUNTS pipeline \ - variable. Compiled:\n{compiled}" - ); - assert!( - !compiled.contains(r#"--mount "/usr/bin/az:/usr/bin/az:ro""#), - "compiled YAML must NOT contain a static --mount for /usr/bin/az — \ - that would crash `docker run` on runners without azure-cli. \ - Mounts must be contributed via the AW_AZ_MOUNTS pipeline \ - variable. Compiled:\n{compiled}" - ); - - // (4) Azure auth/management hosts must be in --allow-domains. - for host in [ - "login.microsoftonline.com", - "management.azure.com", - "graph.microsoft.com", - ] { - assert!( - compiled.contains(host), - "compiled --allow-domains must contain {host}. Compiled:\n{compiled}" - ); - } - - // (5) Regression guard: we deliberately do NOT install az; the host - // is assumed to have azure-cli pre-installed (gh-aw parity). If a - // future contributor adds an install step we want the test suite to - // catch it so the decision is explicit. - assert!( - !compiled.contains("Install Azure CLI"), - "compiled YAML must not contain an 'Install Azure CLI' step — host is assumed \ - to have az pre-installed. If you genuinely need an install step, update this \ - test along with the AzureCliExtension. Compiled:\n{compiled}" - ); - assert!( - !compiled.contains("InstallAzureCLIDeb"), - "compiled YAML must not reference the Microsoft az apt installer URL — host is \ - assumed to have az pre-installed. Compiled:\n{compiled}" - ); -} - // ─── ado-aw-debug fixture ────────────────────────────────────────────────── /// Compile the `ado-aw-debug-agent.md` fixture and assert the @@ -9864,68 +9778,6 @@ fn test_issue_1731_split_checkout_layout_compiles_for_every_target() { } } -#[test] -fn test_azure_cli_smoke_uses_non_blocking_noop_flow() { - let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests") - .join("safe-outputs") - .join("azure-cli.md"); - let fixture = fs::read_to_string(fixture_path) - .expect("read azure-cli smoke fixture") - .replace("\r\n", "\n"); - - for contract in [ - "Capture the combined stdout/stderr", - "Invoke exactly one MCP tool: `noop` from the `safeoutputs`", - "bash:\n - az\n - head", - "edit: false", - "Do not inspect MCP configuration", - "Do not invoke SafeOutputs through", - "Actually invoke the MCP tool", - ] { - assert!( - fixture.contains(contract), - "Azure CLI smoke must preserve its non-blocking restricted flow; missing contract: {contract}" - ); - } - for forbidden in ["report-incomplete", "Do not call `noop`"] { - assert!( - !fixture.contains(forbidden), - "Azure CLI smoke must not fail the candidate lane on unavailable direct auth: {forbidden}" - ); - } - - let (ok, compiled, stderr) = compile_inline_source("azure-cli-smoke-tool-policy", &fixture); - assert!(ok, "Azure CLI smoke should compile:\n{stderr}"); - let document = parse_compiled_yaml(&compiled); - assert_job_execution_env_excludes_ado_credentials( - &document, - "Agent", - "=== Running AI agent with AWF", - "Azure CLI smoke Agent", - ); - let agent = find_job_mapping_by_display_name(&document, "Agent") - .expect("Azure CLI smoke should contain the Agent job"); - let run_agent = find_bash_step_containing(agent, "=== Running AI agent with AWF") - .expect("Azure CLI smoke should contain the Agent execution step"); - let command = run_agent - .get(yaml_key("bash")) - .and_then(|value| value.as_str()) - .expect("Agent execution step should have a bash body"); - for required in ["shell(az)", "shell(head)"] { - assert!( - command.contains(required), - "Azure CLI Agent command should allow only its required shell command {required}:\n{command}" - ); - } - for forbidden in ["--allow-all-tools", "--allow-all-paths"] { - assert!( - !command.contains(forbidden), - "Azure CLI Agent command must not contain {forbidden}:\n{command}" - ); - } -} - // ─── Custom safe-output jobs acceptance matrix ─────────────────────────────── /// Compile the custom jobs-style fixture with the front-matter `target:` diff --git a/tests/fixtures/ado-proxy-read-only-agent.md b/tests/fixtures/ado-proxy-read-only-agent.md new file mode 100644 index 000000000..ade530b3f --- /dev/null +++ b/tests/fixtures/ado-proxy-read-only-agent.md @@ -0,0 +1,13 @@ +--- +name: "ADO proxy read-only az agent" +description: "permissions.read enables wrapped az without the Azure DevOps MCP" +tools: + bash: [head] + azure-devops: false +permissions: + read: my-read-arm-connection +safe-outputs: + noop: {} +--- + +Use wrapped Azure CLI reads, then emit noop. diff --git a/tests/fixtures/no-ado-read-agent.md b/tests/fixtures/no-ado-read-agent.md new file mode 100644 index 000000000..e1e2cfb13 --- /dev/null +++ b/tests/fixtures/no-ado-read-agent.md @@ -0,0 +1,8 @@ +--- +name: "No ADO read agent" +description: "No read permission means no proxy and no Azure CLI exposure" +safe-outputs: + noop: {} +--- + +Emit noop. From a3f11229def2aa496705f3adbd6e997cd40fa0de Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 5 Aug 2026 20:30:48 +0100 Subject: [PATCH 38/42] test(smoke): remove obsolete unproxied azure-cli case Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- .../__tests__/index.test.ts | 10 +--- tests/safe-outputs/README.md | 1 - tests/safe-outputs/azure-cli.md | 57 ------------------- tests/smoke/README.md | 2 +- tests/smoke/REGISTERED.md | 1 - tests/smoke/cases.json | 13 ----- 6 files changed, 4 insertions(+), 80 deletions(-) delete mode 100644 tests/safe-outputs/azure-cli.md diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts index f92a44dd4..f0fd3415f 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts @@ -255,7 +255,6 @@ describe("smoke-e2e index.main (happy path, candidate mode)", () => { // Candidate mode runs exactly the cases the manifest declares for it. expect(queuedCaseIds).toEqual([ "canary", - "azure-cli", "ado-proxy", "noop-target", "custom-safe-output", @@ -264,7 +263,6 @@ describe("smoke-e2e index.main (happy path, candidate mode)", () => { expect(queuedCaseIds).not.toContain("janitor"); expect(compiledCasePaths).toEqual([ "tests/safe-outputs/canary.md", - "tests/safe-outputs/azure-cli.md", "tests/smoke/ado-proxy.md", "tests/safe-outputs/noop-target.md", "tests/smoke/custom-safe-output.md", @@ -283,14 +281,13 @@ describe("smoke-e2e index.main (happy path, candidate mode)", () => { expect(queuedRequests.map((r) => r.sourceBranch)).toEqual([ "refs/heads/ado-aw-smoke-candidate/630001/canary", - "refs/heads/ado-aw-smoke-candidate/630001/azure-cli", "refs/heads/ado-aw-smoke-candidate/630001/ado-proxy", "refs/heads/ado-aw-smoke-candidate/630001/noop-target", "refs/heads/ado-aw-smoke-candidate/630001/custom-safe-output", "refs/heads/ado-aw-smoke-candidate/630001/multi-repo", ]); // Every case is staged to the SAME path — the ref is what distinguishes them. - expect(stagedWrites.length).toBe(6); + expect(stagedWrites.length).toBe(5); for (const write of stagedWrites) { expect(write.to).toBe(join(WORKTREE, "candidate", ".smoke", "pipeline.yml")); // The compiler emits no trigger keys once `on:` is stripped, and a @@ -320,7 +317,7 @@ describe("smoke-e2e index.main (happy path, candidate mode)", () => { const gitModule = await import("../git.js"); const resets = vi.mocked(gitModule.resetWorktree).mock.calls; - expect(resets.length).toBe(6); + expect(resets.length).toBe(5); for (const call of resets) { expect(call[0]).toMatchObject({ commitish: "basecommit" }); } @@ -444,12 +441,11 @@ describe("smoke-e2e index.main (per-case ref retention)", () => { // build stranded every case's ref. expect(deletedRefs).toEqual([ "refs/heads/ado-aw-smoke-candidate/630001/canary", - "refs/heads/ado-aw-smoke-candidate/630001/ado-proxy", "refs/heads/ado-aw-smoke-candidate/630001/noop-target", "refs/heads/ado-aw-smoke-candidate/630001/custom-safe-output", "refs/heads/ado-aw-smoke-candidate/630001/multi-repo", ]); - expect(deletedRefs).not.toContain("refs/heads/ado-aw-smoke-candidate/630001/azure-cli"); + expect(deletedRefs).not.toContain("refs/heads/ado-aw-smoke-candidate/630001/ado-proxy"); }); it("retains every pushed ref when runFixtures throws, because builds may already be queued", async () => { diff --git a/tests/safe-outputs/README.md b/tests/safe-outputs/README.md index 53daa5ec4..4a8e22c11 100644 --- a/tests/safe-outputs/README.md +++ b/tests/safe-outputs/README.md @@ -29,7 +29,6 @@ A single successful run proves all three. | Source | Purpose | | --- | --- | | `canary.md` | Omnibus canary: the agent emits `noop` + `create-work-item` + `add-build-tag` in one run. Proves the full agentic loop with two distinct ADO write paths. | -| `azure-cli.md` | Verifies the generated `az` wrapper reaches Azure DevOps through `ado-proxy` with sentinel client auth, while the proxy injects the real bearer only after policy allows the read. | | `noop-target.md` | Minimal agentic pipeline. (The executor-e2e `queue-build` target is now the separate, non-agentic [`tests/executor-e2e/queue-target.yml`](../executor-e2e/queue-target.yml).) | | `janitor.md` | Prunes `ado-aw-smoke-*` artifacts (work items, branches, wiki pages, tags, PRs) older than 30 days from AgentPlayground. Runs in released mode. | diff --git a/tests/safe-outputs/azure-cli.md b/tests/safe-outputs/azure-cli.md deleted file mode 100644 index a497cc7ef..000000000 --- a/tests/safe-outputs/azure-cli.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: "Daily smoke: az CLI access" -description: "Exercises that az is mounted and reachable inside the AWF container" -on: - schedule: daily around 03:00 -target: standalone -pool: - name: AZS-1ES-L-Playground-ubuntu-22.04 -engine: - id: copilot - model: claude-sonnet-4.6 - timeout-minutes: 15 -tools: - bash: - - az - - head - edit: false -permissions: - read: agent-playground-read -safe-outputs: - noop: {} ---- - -## Daily smoke for Azure CLI (az) - -You are a smoke test. Verify the host-mounted Azure CLI is reachable -inside the AWF container, then emit exactly one safe-output. - -Steps (run each in turn using your bash tool): - -1. Confirm the binary exists and prints its version: - - ``` - az --version | head -3 - ``` - -2. Confirm the Azure DevOps command group is installed and can render help. - This smoke does not expect direct ADO authentication: - - ``` - az devops -h | head -20 - ``` - - Capture the combined stdout/stderr (truncated to 400 characters if longer) - for the safe-output context below. - -3. Invoke exactly one MCP tool: `noop` from the `safeoutputs` - server, with: - - - context: a brief one-line proof-of-life containing the az version - string and command-group help output, prefixed with - `ado-aw-smoke-$(Build.BuildId)-azure-cli:`. - -Use the native Copilot MCP tool interface. Do not inspect MCP configuration, -API keys, processes, files, or HTTP endpoints. Do not invoke SafeOutputs through -bash, `curl`, or raw HTTP. Do not print or describe a JSON tool request. -Actually invoke the MCP tool, then stop. diff --git a/tests/smoke/README.md b/tests/smoke/README.md index 0060fef4f..d6ddf30c7 100644 --- a/tests/smoke/README.md +++ b/tests/smoke/README.md @@ -35,7 +35,7 @@ credential class: | Lane | Secrets / service connections | Cases | | --- | --- | --- | -| `agentic` | `GITHUB_TOKEN`, `agent-playground-read`/`-write` | canary, azure-cli, ado-proxy, noop-target, custom-safe-output, multi-repo, janitor | +| `agentic` | `GITHUB_TOKEN`, `agent-playground-read`/`-write` | canary, ado-proxy, noop-target, custom-safe-output, multi-repo, janitor | | `infra` | none | *(reserved for AWF and the ado-proxy sidecar)* | No case currently files GitHub issues, so the lane holds no GitHub PAT beyond diff --git a/tests/smoke/REGISTERED.md b/tests/smoke/REGISTERED.md index da0652278..9249482cf 100644 --- a/tests/smoke/REGISTERED.md +++ b/tests/smoke/REGISTERED.md @@ -206,7 +206,6 @@ orchestrators, then deleting the retired definitions. | case | build | result | | --- | ---: | --- | | canary | `629523` | succeeded | - | azure-cli | `629525` | succeeded | | noop-target | `629524` | succeeded | | custom-safe-output | `629527` | succeeded | | multi-repo | `629526` | succeeded | diff --git a/tests/smoke/cases.json b/tests/smoke/cases.json index 5e29960c2..79cffd7fa 100644 --- a/tests/smoke/cases.json +++ b/tests/smoke/cases.json @@ -19,19 +19,6 @@ "modes": ["candidate", "released"], "source": "tests/safe-outputs/canary.md" }, - { - "id": "azure-cli", - "lane": "agentic", - "kind": "compiled", - "modes": ["candidate", "released"], - "source": "tests/safe-outputs/azure-cli.md", - "assertions": { - "agentCommand": { - "required": ["shell(az", "shell(head"], - "forbidden": ["--allow-all-tools", "--allow-all-paths"] - } - } - }, { "id": "ado-proxy", "lane": "agentic", From 78a90d8ee14e02b50e588eb73109d998f7fd4e61 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 5 Aug 2026 21:32:21 +0100 Subject: [PATCH 39/42] feat(audit): analyze ado-proxy runtime diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- src/audit/analyzers/ado_proxy.rs | 758 +++++++++++++++++++++++++++++++ src/audit/analyzers/mod.rs | 1 + src/audit/cli.rs | 83 +++- src/audit/findings.rs | 400 +++++++++++++++- src/audit/model.rs | 174 +++++++ src/audit/render/console.rs | 219 ++++++++- src/audit/render/json.rs | 32 ++ src/inspect/trace.rs | 161 ++++++- src/mcp_author/tests.rs | 27 ++ 9 files changed, 1841 insertions(+), 14 deletions(-) create mode 100644 src/audit/analyzers/ado_proxy.rs diff --git a/src/audit/analyzers/ado_proxy.rs b/src/audit/analyzers/ado_proxy.rs new file mode 100644 index 000000000..658606a49 --- /dev/null +++ b/src/audit/analyzers/ado_proxy.rs @@ -0,0 +1,758 @@ +//! Sanitized `ado-proxy` decision and lifecycle log analyzer. + +use std::collections::{BTreeMap, VecDeque}; +use std::io::ErrorKind; +use std::path::Path; + +use anyhow::Context; +use serde::Deserialize; +use tokio::io::{AsyncBufReadExt, BufReader}; + +use crate::audit::model::{ + AdoProxyAnalysis, AdoProxyEventSummary, AdoProxyLatencyStats, AdoProxyLifecycle, + AdoProxyOperationStat, AdoProxyReasonStat, +}; + +const DECISION_LOG_SCHEMA: &str = "ado-aw/ado-proxy-decisions/v1"; +const DECISION_LOG_FILE: &str = "ado-proxy-decisions.jsonl"; +const CONTAINER_LOG_FILE: &str = "container.log"; +const CONTAINER_STATE_FILE: &str = "container-state.txt"; +const MAX_PROBLEM_EVENTS: usize = 20; +const MAX_LIFECYCLE_DIAGNOSTICS: usize = 20; +const MAX_DETAIL_CHARS: usize = 512; + +/// Result of analyzing one `logs/ado-proxy` directory. +#[derive(Debug, Default)] +pub struct AdoProxyAnalysisResult { + pub analysis: Option, + pub warnings: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DecisionLogHeader { + schema: String, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum DecisionKind { + Allow, + Deny, + Error, +} + +impl DecisionKind { + fn as_str(self) -> &'static str { + match self { + Self::Allow => "allow", + Self::Deny => "deny", + Self::Error => "error", + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DecisionRecord { + ts: String, + request_id: String, + host: String, + method: String, + operation: Option, + decision: DecisionKind, + reason: Option, + detail: Option, + upstream_status_class: Option, + latency_ms: Option, + response_bytes: Option, + #[serde(default)] + stripped_credentials: Vec, +} + +#[derive(Debug, Default)] +struct LatencyAccumulator { + observed_count: u64, + total_ms: u64, + max_ms: u64, +} + +impl LatencyAccumulator { + fn record(&mut self, latency_ms: Option) { + let Some(latency_ms) = latency_ms else { + return; + }; + self.observed_count += 1; + self.total_ms = self.total_ms.saturating_add(latency_ms); + self.max_ms = self.max_ms.max(latency_ms); + } + + fn finish(self) -> Option { + if self.observed_count == 0 { + return None; + } + Some(AdoProxyLatencyStats { + observed_count: self.observed_count, + total_ms: self.total_ms, + average_ms: self.total_ms as f64 / self.observed_count as f64, + max_ms: self.max_ms, + }) + } +} + +#[derive(Debug, Default)] +struct OperationAccumulator { + request_count: u64, + allow_count: u64, + deny_count: u64, + error_count: u64, + latency: LatencyAccumulator, + response_bytes: u64, +} + +impl OperationAccumulator { + fn record(&mut self, record: &DecisionRecord) { + self.request_count += 1; + match record.decision { + DecisionKind::Allow => self.allow_count += 1, + DecisionKind::Deny => self.deny_count += 1, + DecisionKind::Error => self.error_count += 1, + } + self.latency.record(record.latency_ms); + self.response_bytes = self + .response_bytes + .saturating_add(record.response_bytes.unwrap_or_default()); + } + + fn finish(self, operation: Option) -> AdoProxyOperationStat { + AdoProxyOperationStat { + operation, + request_count: self.request_count, + allow_count: self.allow_count, + deny_count: self.deny_count, + error_count: self.error_count, + latency: self.latency.finish(), + response_bytes: self.response_bytes, + } + } +} + +#[derive(Debug, Default)] +struct DecisionAccumulator { + total_requests: u64, + allow_count: u64, + deny_count: u64, + error_count: u64, + operations: BTreeMap, OperationAccumulator>, + reasons: BTreeMap<(String, String), u64>, + upstream_status_classes: BTreeMap, + latency: LatencyAccumulator, + response_bytes: u64, + stripped_credentials: BTreeMap, + recent_problem_events: VecDeque, +} + +impl DecisionAccumulator { + fn record(&mut self, record: DecisionRecord) { + self.total_requests += 1; + match record.decision { + DecisionKind::Allow => self.allow_count += 1, + DecisionKind::Deny => self.deny_count += 1, + DecisionKind::Error => self.error_count += 1, + } + + self.operations + .entry(record.operation.clone()) + .or_default() + .record(&record); + + if let Some(reason) = record.reason.as_deref().filter(|reason| !reason.is_empty()) { + *self + .reasons + .entry((record.decision.as_str().to_string(), reason.to_string())) + .or_default() += 1; + } + + if let Some(status_class) = record + .upstream_status_class + .as_deref() + .filter(|status_class| !status_class.is_empty()) + { + *self + .upstream_status_classes + .entry(status_class.to_string()) + .or_default() += 1; + } + + self.latency.record(record.latency_ms); + self.response_bytes = self + .response_bytes + .saturating_add(record.response_bytes.unwrap_or_default()); + + for header in &record.stripped_credentials { + let header = header.trim().to_ascii_lowercase(); + if !header.is_empty() { + *self.stripped_credentials.entry(header).or_default() += 1; + } + } + + if matches!(record.decision, DecisionKind::Deny | DecisionKind::Error) { + if self.recent_problem_events.len() == MAX_PROBLEM_EVENTS { + self.recent_problem_events.pop_front(); + } + self.recent_problem_events.push_back(AdoProxyEventSummary { + timestamp: non_empty(record.ts), + request_id: non_empty(record.request_id), + host: non_empty(record.host), + method: non_empty(record.method), + operation: record.operation.and_then(non_empty), + decision: record.decision.as_str().to_string(), + reason: record.reason.and_then(non_empty), + detail: record.detail.and_then(|detail| normalize_text(&detail)), + upstream_status_class: record.upstream_status_class.and_then(non_empty), + latency_ms: record.latency_ms, + }); + } + } + + fn apply(self, analysis: &mut AdoProxyAnalysis) { + analysis.total_requests = self.total_requests; + analysis.allow_count = self.allow_count; + analysis.deny_count = self.deny_count; + analysis.error_count = self.error_count; + analysis.upstream_status_classes = self.upstream_status_classes; + analysis.latency = self.latency.finish(); + analysis.response_bytes = self.response_bytes; + analysis.stripped_credentials = self.stripped_credentials; + analysis.recent_problem_events = self.recent_problem_events.into_iter().collect(); + + analysis.operations = self + .operations + .into_iter() + .map(|(operation, stats)| stats.finish(operation)) + .collect(); + analysis.operations.sort_by(|left, right| { + right.request_count.cmp(&left.request_count).then_with(|| { + match (&left.operation, &right.operation) { + (Some(left), Some(right)) => left.cmp(right), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + }) + }); + + analysis.reasons = self + .reasons + .into_iter() + .map(|((decision, reason), count)| AdoProxyReasonStat { + reason, + decision, + count, + }) + .collect(); + analysis.reasons.sort_by(|left, right| { + right + .count + .cmp(&left.count) + .then_with(|| left.decision.cmp(&right.decision)) + .then_with(|| left.reason.cmp(&right.reason)) + }); + } +} + +/// Analyze sanitized proxy diagnostics under `/logs/ado-proxy`. +pub async fn analyze_ado_proxy_logs(logs_dir: &Path) -> anyhow::Result { + match tokio::fs::metadata(logs_dir).await { + Ok(metadata) => { + anyhow::ensure!( + metadata.is_dir(), + "ado-proxy logs path is not a directory: {}", + logs_dir.display() + ); + } + Err(error) if error.kind() == ErrorKind::NotFound => { + return Ok(AdoProxyAnalysisResult::default()); + } + Err(error) => { + return Err(error).with_context(|| format!("Failed to stat {}", logs_dir.display())); + } + } + + let mut result = AdoProxyAnalysisResult::default(); + let lifecycle = analyze_lifecycle(logs_dir, &mut result.warnings).await?; + let mut analysis = AdoProxyAnalysis { + lifecycle, + ..AdoProxyAnalysis::default() + }; + + let decision_evidence = + analyze_decisions(logs_dir, &mut analysis, &mut result.warnings).await?; + if analysis.lifecycle.is_some() || decision_evidence { + result.analysis = Some(analysis); + } + Ok(result) +} + +async fn analyze_lifecycle( + logs_dir: &Path, + warnings: &mut Vec, +) -> anyhow::Result> { + let mut lifecycle = AdoProxyLifecycle::default(); + let mut saw_evidence = false; + + let state_path = logs_dir.join(CONTAINER_STATE_FILE); + if let Some(contents) = read_optional_file(&state_path).await? { + let trimmed = contents.trim(); + if !trimmed.is_empty() { + saw_evidence = true; + parse_container_state(trimmed, &mut lifecycle, warnings); + } + } + + let log_path = logs_dir.join(CONTAINER_LOG_FILE); + if let Some(file) = open_optional_file(&log_path).await? { + let mut lines = BufReader::new(file).lines(); + while let Some(line) = lines + .next_line() + .await + .with_context(|| format!("Failed to read {}", log_path.display()))? + { + let Some(message) = line.strip_prefix("[ado-proxy] ") else { + continue; + }; + if message.starts_with("listening on ") { + lifecycle.listening = true; + saw_evidence = true; + continue; + } + if is_lifecycle_failure(message) { + saw_evidence = true; + if lifecycle.diagnostics.len() < MAX_LIFECYCLE_DIAGNOSTICS + && let Some(message) = normalize_text(message) + { + lifecycle.diagnostics.push(message); + } + } + } + } + + if !saw_evidence { + return Ok(None); + } + + lifecycle.healthy_before_teardown = lifecycle.listening + && lifecycle.state_before_teardown.as_deref() == Some("running") + && lifecycle.docker_error.is_none() + && lifecycle.diagnostics.is_empty(); + Ok(Some(lifecycle)) +} + +fn parse_container_state( + line: &str, + lifecycle: &mut AdoProxyLifecycle, + warnings: &mut Vec, +) { + if line == "state=missing before teardown" { + lifecycle.state_before_teardown = Some("missing".to_string()); + return; + } + let Some(rest) = line.strip_prefix("state=") else { + warnings.push(format!( + "{CONTAINER_STATE_FILE} did not match the compiler-owned lifecycle format" + )); + return; + }; + let Some((state, rest)) = rest.split_once(" exit=") else { + warnings.push(format!( + "{CONTAINER_STATE_FILE} did not match the compiler-owned lifecycle format" + )); + return; + }; + let Some((exit_code, docker_error)) = rest.split_once(" error=") else { + warnings.push(format!( + "{CONTAINER_STATE_FILE} did not match the compiler-owned lifecycle format" + )); + return; + }; + + lifecycle.state_before_teardown = normalize_text(state); + match exit_code.parse::() { + Ok(exit_code) => lifecycle.exit_code_before_teardown = Some(exit_code), + Err(_) => warnings.push(format!( + "{CONTAINER_STATE_FILE} contained a non-integer exit code" + )), + } + lifecycle.docker_error = normalize_text(docker_error); +} + +fn is_lifecycle_failure(message: &str) -> bool { + [ + "configuration error:", + "cannot establish the interception identity:", + "decision log disabled:", + "decision log write failed:", + ] + .iter() + .any(|prefix| message.starts_with(prefix)) +} + +async fn analyze_decisions( + logs_dir: &Path, + analysis: &mut AdoProxyAnalysis, + warnings: &mut Vec, +) -> anyhow::Result { + let path = logs_dir.join(DECISION_LOG_FILE); + let Some(file) = open_optional_file(&path).await? else { + return Ok(false); + }; + + let mut lines = BufReader::new(file).lines(); + let mut header_line = None; + while let Some(line) = lines + .next_line() + .await + .with_context(|| format!("Failed to read {}", path.display()))? + { + if !line.trim().is_empty() { + header_line = Some(line); + break; + } + } + + let Some(header_line) = header_line else { + warnings.push(format!("{DECISION_LOG_FILE} contained no schema header")); + return Ok(false); + }; + let header: DecisionLogHeader = match serde_json::from_str(header_line.trim()) { + Ok(header) => header, + Err(_) => { + warnings.push(format!( + "{DECISION_LOG_FILE} contained an invalid schema header" + )); + return Ok(false); + } + }; + if header.schema != DECISION_LOG_SCHEMA { + warnings.push(format!( + "{DECISION_LOG_FILE} uses unsupported schema '{}'; expected {DECISION_LOG_SCHEMA}", + normalize_text(&header.schema).unwrap_or_else(|| "(empty)".to_string()) + )); + return Ok(false); + } + + analysis.schema_version = Some(header.schema); + let mut accumulator = DecisionAccumulator::default(); + let mut malformed = 0_u64; + while let Some(line) = lines + .next_line() + .await + .with_context(|| format!("Failed to read {}", path.display()))? + { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + match serde_json::from_str::(trimmed) { + Ok(record) => accumulator.record(record), + Err(_) => malformed += 1, + } + } + + accumulator.apply(analysis); + analysis.malformed_record_count = malformed; + if malformed > 0 { + warnings.push(format!( + "{DECISION_LOG_FILE} contained {malformed} malformed decision record(s)" + )); + } + Ok(true) +} + +async fn open_optional_file(path: &Path) -> anyhow::Result> { + match tokio::fs::File::open(path).await { + Ok(file) => Ok(Some(file)), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("Failed to open {}", path.display())), + } +} + +async fn read_optional_file(path: &Path) -> anyhow::Result> { + match tokio::fs::read_to_string(path).await { + Ok(contents) => Ok(Some(contents)), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("Failed to read {}", path.display())), + } +} + +fn normalize_text(value: &str) -> Option { + let normalized: String = value + .chars() + .filter(|character| !character.is_control()) + .take(MAX_DETAIL_CHARS) + .collect(); + non_empty(normalized.trim().to_string()) +} + +fn non_empty(value: String) -> Option { + (!value.trim().is_empty()).then_some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + async fn write(dir: &Path, name: &str, contents: &str) { + tokio::fs::create_dir_all(dir).await.unwrap(); + tokio::fs::write(dir.join(name), contents).await.unwrap(); + } + + fn header() -> &'static str { + "{\"schema\":\"ado-aw/ado-proxy-decisions/v1\"}\n" + } + + #[tokio::test] + async fn missing_directory_returns_none() { + let temp = TempDir::new().unwrap(); + let result = analyze_ado_proxy_logs(&temp.path().join("missing")) + .await + .unwrap(); + assert!(result.analysis.is_none()); + assert!(result.warnings.is_empty()); + } + + #[tokio::test] + async fn empty_directory_returns_none() { + let temp = TempDir::new().unwrap(); + let result = analyze_ado_proxy_logs(temp.path()).await.unwrap(); + assert!(result.analysis.is_none()); + } + + #[tokio::test] + async fn aggregates_valid_decisions_deterministically() { + let temp = TempDir::new().unwrap(); + write( + temp.path(), + DECISION_LOG_FILE, + &format!( + "{}{}{}{}", + header(), + "{\"ts\":\"2026-01-01T00:00:00Z\",\"request_id\":\"1\",\"host\":\"dev.azure.com\",\"method\":\"GET\",\"operation\":\"core.project.get\",\"decision\":\"allow\",\"upstream_status_class\":\"2xx\",\"latency_ms\":10,\"response_bytes\":100,\"stripped_credentials\":[\"Authorization\"]}\n", + "{\"ts\":\"2026-01-01T00:00:01Z\",\"request_id\":\"2\",\"host\":\"dev.azure.com\",\"method\":\"POST\",\"decision\":\"deny\",\"reason\":\"method-not-read\",\"detail\":\"POST is not a read method\",\"stripped_credentials\":[]}\n", + "{\"ts\":\"2026-01-01T00:00:02Z\",\"request_id\":\"3\",\"host\":\"dev.azure.com\",\"method\":\"GET\",\"operation\":\"core.project.get\",\"decision\":\"error\",\"reason\":\"upstream-failed\",\"detail\":\"network down\",\"latency_ms\":20,\"stripped_credentials\":[\"authorization\"]}\n" + ), + ) + .await; + + let result = analyze_ado_proxy_logs(temp.path()).await.unwrap(); + let analysis = result.analysis.unwrap(); + assert_eq!(analysis.total_requests, 3); + assert_eq!(analysis.allow_count, 1); + assert_eq!(analysis.deny_count, 1); + assert_eq!(analysis.error_count, 1); + assert_eq!(analysis.response_bytes, 100); + assert_eq!(analysis.upstream_status_classes["2xx"], 1); + assert_eq!(analysis.stripped_credentials["authorization"], 2); + assert_eq!( + analysis.latency, + Some(AdoProxyLatencyStats { + observed_count: 2, + total_ms: 30, + average_ms: 15.0, + max_ms: 20, + }) + ); + assert_eq!(analysis.operations.len(), 2); + assert_eq!( + analysis.operations[0].operation.as_deref(), + Some("core.project.get") + ); + assert_eq!(analysis.operations[0].request_count, 2); + assert!(analysis.operations[1].operation.is_none()); + assert_eq!( + analysis + .reasons + .iter() + .map(|reason| ( + reason.decision.as_str(), + reason.reason.as_str(), + reason.count + )) + .collect::>(), + vec![ + ("deny", "method-not-read", 1), + ("error", "upstream-failed", 1) + ] + ); + assert_eq!(analysis.recent_problem_events.len(), 2); + assert!(result.warnings.is_empty()); + } + + #[tokio::test] + async fn retains_only_the_final_twenty_problem_events() { + let temp = TempDir::new().unwrap(); + let mut contents = header().to_string(); + for index in 0..25 { + contents.push_str(&format!( + "{{\"ts\":\"2026-01-01T00:00:{index:02}Z\",\"request_id\":\"{index}\",\"host\":\"dev.azure.com\",\"method\":\"GET\",\"decision\":\"deny\",\"reason\":\"out-of-scope\",\"stripped_credentials\":[]}}\n" + )); + } + write(temp.path(), DECISION_LOG_FILE, &contents).await; + + let analysis = analyze_ado_proxy_logs(temp.path()) + .await + .unwrap() + .analysis + .unwrap(); + assert_eq!(analysis.recent_problem_events.len(), 20); + assert_eq!( + analysis.recent_problem_events[0].request_id.as_deref(), + Some("5") + ); + assert_eq!( + analysis.recent_problem_events[19].request_id.as_deref(), + Some("24") + ); + } + + #[tokio::test] + async fn malformed_records_are_counted_without_echoing_content() { + let temp = TempDir::new().unwrap(); + write( + temp.path(), + DECISION_LOG_FILE, + &format!( + "{}{}\n{}", + header(), + "{\"secret\":\"must-not-appear\"}", + "{\"ts\":\"2026-01-01T00:00:00Z\",\"request_id\":\"1\",\"host\":\"dev.azure.com\",\"method\":\"GET\",\"decision\":\"allow\",\"stripped_credentials\":[]}\n" + ), + ) + .await; + + let result = analyze_ado_proxy_logs(temp.path()).await.unwrap(); + let analysis = result.analysis.unwrap(); + assert_eq!(analysis.malformed_record_count, 1); + assert_eq!(analysis.total_requests, 1); + assert_eq!(result.warnings.len(), 1); + assert!(!result.warnings[0].contains("must-not-appear")); + assert!( + !serde_json::to_string(&analysis) + .unwrap() + .contains("must-not-appear") + ); + } + + #[tokio::test] + async fn unknown_schema_preserves_lifecycle() { + let temp = TempDir::new().unwrap(); + write( + temp.path(), + CONTAINER_STATE_FILE, + "state=running exit=0 error=\n", + ) + .await; + write( + temp.path(), + CONTAINER_LOG_FILE, + "[ado-proxy] listening on 0.0.0.0:11080\n", + ) + .await; + write( + temp.path(), + DECISION_LOG_FILE, + "{\"schema\":\"ado-aw/ado-proxy-decisions/v2\"}\n", + ) + .await; + + let result = analyze_ado_proxy_logs(temp.path()).await.unwrap(); + let analysis = result.analysis.unwrap(); + assert!(analysis.schema_version.is_none()); + assert!(analysis.lifecycle.unwrap().healthy_before_teardown); + assert_eq!(result.warnings.len(), 1); + } + + #[tokio::test] + async fn missing_schema_warns_without_analysis() { + let temp = TempDir::new().unwrap(); + write(temp.path(), DECISION_LOG_FILE, "").await; + let result = analyze_ado_proxy_logs(temp.path()).await.unwrap(); + assert!(result.analysis.is_none()); + assert_eq!(result.warnings.len(), 1); + } + + #[tokio::test] + async fn lifecycle_failures_are_bounded_and_unhealthy() { + let temp = TempDir::new().unwrap(); + write( + temp.path(), + CONTAINER_STATE_FILE, + "state=exited exit=1 error=container failed\u{1b}[31m\n", + ) + .await; + let long = "x".repeat(600); + write( + temp.path(), + CONTAINER_LOG_FILE, + &format!( + "[ado-proxy] configuration error: {long}\n[ado-proxy] decision log write failed: disk full\n" + ), + ) + .await; + + let analysis = analyze_ado_proxy_logs(temp.path()) + .await + .unwrap() + .analysis + .unwrap(); + let lifecycle = analysis.lifecycle.unwrap(); + assert_eq!(lifecycle.state_before_teardown.as_deref(), Some("exited")); + assert_eq!(lifecycle.exit_code_before_teardown, Some(1)); + assert_eq!( + lifecycle.docker_error.as_deref(), + Some("container failed[31m") + ); + assert!(!lifecycle.listening); + assert!(!lifecycle.healthy_before_teardown); + assert_eq!(lifecycle.diagnostics.len(), 2); + assert_eq!(lifecycle.diagnostics[0].chars().count(), MAX_DETAIL_CHARS); + } + + #[tokio::test] + async fn invalid_exit_code_warns_without_aborting() { + let temp = TempDir::new().unwrap(); + write( + temp.path(), + CONTAINER_STATE_FILE, + "state=running exit=not-a-number error=\n", + ) + .await; + let result = analyze_ado_proxy_logs(temp.path()).await.unwrap(); + let lifecycle = result.analysis.unwrap().lifecycle.unwrap(); + assert_eq!(lifecycle.state_before_teardown.as_deref(), Some("running")); + assert_eq!(lifecycle.exit_code_before_teardown, None); + assert_eq!(result.warnings.len(), 1); + } + + #[tokio::test] + async fn missing_before_teardown_state_is_preserved() { + let temp = TempDir::new().unwrap(); + write( + temp.path(), + CONTAINER_STATE_FILE, + "state=missing before teardown\n", + ) + .await; + + let lifecycle = analyze_ado_proxy_logs(temp.path()) + .await + .unwrap() + .analysis + .unwrap() + .lifecycle + .unwrap(); + assert_eq!(lifecycle.state_before_teardown.as_deref(), Some("missing")); + assert!(!lifecycle.healthy_before_teardown); + } +} diff --git a/src/audit/analyzers/mod.rs b/src/audit/analyzers/mod.rs index 229d4456b..520367682 100644 --- a/src/audit/analyzers/mod.rs +++ b/src/audit/analyzers/mod.rs @@ -4,6 +4,7 @@ //! Each submodule owns one signal: firewall, mcp, otel, safe-outputs, //! detection, custom safe-output jobs, missing-tools/data/noops, build timeline. +pub mod ado_proxy; pub mod custom_jobs; pub mod detection; pub mod firewall; diff --git a/src/audit/cli.rs b/src/audit/cli.rs index 0733b3b14..37b00faa8 100644 --- a/src/audit/cli.rs +++ b/src/audit/cli.rs @@ -10,7 +10,7 @@ use crate::ado::{ resolve_ado_context, resolve_auth, }; use crate::audit::analyzers::{ - custom_jobs, detection, firewall, jobs, mcp, missing, otel, policy, safe_outputs, + ado_proxy, custom_jobs, detection, firewall, jobs, mcp, missing, otel, policy, safe_outputs, }; use crate::audit::cache::{RunSummary, load_run_summary, save_run_summary}; use crate::audit::findings; @@ -467,6 +467,27 @@ fn apply_safe_output_analysis(audit: &mut AuditData, result: safe_outputs::SafeO /// Run analyzers that operate on the `agent_outputs` artifact directory. async fn run_agent_output_analyzers(agent_outputs_dir: &Path, audit: &mut AuditData) { + let ado_proxy_dir = agent_outputs_dir.join("logs").join("ado-proxy"); + run_analyzer( + audit, + "audit::ado_proxy", + "ado-proxy analysis failed", + ado_proxy::analyze_ado_proxy_logs(&ado_proxy_dir).await, + |a, result| { + a.ado_proxy_analysis = result.analysis; + for message in result.warnings { + crate::audit::push_warning_once( + a, + ErrorInfo { + source: String::from("audit::ado_proxy"), + message, + timestamp: None, + }, + ); + } + }, + ); + let firewall_dir = agent_outputs_dir.join("logs").join("firewall"); run_analyzer( audit, @@ -1018,6 +1039,66 @@ mod tests { use super::*; use crate::audit::model::{CustomSafeOutputJobAudit, Finding, JobData, Recommendation}; + #[tokio::test] + async fn agent_output_analyzers_load_proxy_logs_from_canonical_path() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let proxy_dir = temp_dir.path().join("logs").join("ado-proxy"); + tokio::fs::create_dir_all(&proxy_dir) + .await + .expect("create proxy log directory"); + tokio::fs::write( + proxy_dir.join("ado-proxy-decisions.jsonl"), + concat!( + "{\"schema\":\"ado-aw/ado-proxy-decisions/v1\"}\n", + "{\"ts\":\"2026-01-01T00:00:00Z\",\"request_id\":\"1\",\"host\":\"dev.azure.com\",\"method\":\"GET\",\"operation\":\"core.project.get\",\"decision\":\"allow\",\"stripped_credentials\":[]}\n" + ), + ) + .await + .expect("write proxy decision log"); + + let mut audit = AuditData::default(); + run_agent_output_analyzers(temp_dir.path(), &mut audit).await; + + let analysis = audit.ado_proxy_analysis.expect("proxy analysis populated"); + assert_eq!(analysis.total_requests, 1); + assert_eq!(analysis.allow_count, 1); + assert!( + audit + .warnings + .iter() + .all(|warning| warning.source != "audit::ado_proxy") + ); + } + + #[tokio::test] + async fn agent_output_analyzers_surface_proxy_parse_warnings_without_raw_lines() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let proxy_dir = temp_dir.path().join("logs").join("ado-proxy"); + tokio::fs::create_dir_all(&proxy_dir) + .await + .expect("create proxy log directory"); + tokio::fs::write( + proxy_dir.join("ado-proxy-decisions.jsonl"), + concat!( + "{\"schema\":\"ado-aw/ado-proxy-decisions/v1\"}\n", + "{\"secret\":\"must-not-appear\"}\n" + ), + ) + .await + .expect("write malformed proxy decision log"); + + let mut audit = AuditData::default(); + run_agent_output_analyzers(temp_dir.path(), &mut audit).await; + + let warning = audit + .warnings + .iter() + .find(|warning| warning.source == "audit::ado_proxy") + .expect("proxy warning"); + assert!(warning.message.contains("1 malformed decision record")); + assert!(!warning.message.contains("must-not-appear")); + } + #[tokio::test] async fn cached_reprocessing_resets_derived_state_and_pass_warnings() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/audit/findings.rs b/src/audit/findings.rs index 4f50ebbc7..bcbf7f617 100644 --- a/src/audit/findings.rs +++ b/src/audit/findings.rs @@ -13,6 +13,7 @@ pub fn derive_findings(audit: &mut AuditData) { let mut findings = audit.key_findings.clone(); let mut recommendations = audit.recommendations.clone(); + add_ado_proxy_findings(audit, &mut findings, &mut recommendations); add_elevated_mcp_error_rate(audit, &mut findings, &mut recommendations); add_denied_network_domains(audit, &mut findings, &mut recommendations); add_high_token_usage(audit, &mut findings, &mut recommendations); @@ -27,6 +28,259 @@ pub fn derive_findings(audit: &mut AuditData) { audit.recommendations = recommendations; } +fn add_ado_proxy_findings( + audit: &AuditData, + findings: &mut Vec, + recommendations: &mut Vec, +) { + let Some(proxy) = &audit.ado_proxy_analysis else { + return; + }; + + if proxy + .lifecycle + .as_ref() + .is_some_and(|lifecycle| !lifecycle.healthy_before_teardown) + { + push_finding( + findings, + Finding { + category: String::from("ado_proxy"), + severity: Severity::High, + title: String::from("ado-proxy was not healthy before teardown"), + description: String::from( + "The proxy did not reach or retain its expected running/listening state before teardown.", + ), + impact: Some(String::from( + "Azure DevOps reads through wrapped az or the Azure DevOps MCP may have failed.", + )), + }, + ); + push_recommendation( + recommendations, + Recommendation { + priority: String::from("high"), + action: String::from("Inspect ado-proxy lifecycle diagnostics"), + reason: String::from( + "Container state and startup logs identify topology, CA, configuration, or lifecycle failures.", + ), + example: Some(String::from( + "Inspect agent_outputs_/logs/ado-proxy/container.log and container-state.txt", + )), + }, + ); + } + + let credential_unavailable = proxy_reason_count(proxy, &["credential-unavailable"]); + if credential_unavailable > 0 { + push_finding( + findings, + Finding { + category: String::from("ado_proxy"), + severity: Severity::High, + title: String::from("ado-proxy credential was unavailable"), + description: format!( + "The proxy could not acquire its Azure DevOps read credential for {credential_unavailable} request(s)." + ), + impact: Some(String::from( + "Authorized Azure DevOps reads could not be forwarded upstream.", + )), + }, + ); + push_recommendation( + recommendations, + Recommendation { + priority: String::from("high"), + action: String::from("Inspect the permissions.read service connection"), + reason: String::from( + "The trusted proxy token source failed; the credential must not be moved into the agent.", + ), + example: None, + }, + ); + } + + let upstream_failed = proxy_reason_count(proxy, &["upstream-failed"]); + if upstream_failed > 0 { + push_finding( + findings, + Finding { + category: String::from("ado_proxy"), + severity: Severity::High, + title: String::from("ado-proxy could not reach Azure DevOps upstream"), + description: format!( + "{upstream_failed} authorized request(s) failed while reaching the upstream service." + ), + impact: Some(String::from( + "The agent's Azure DevOps reads may be incomplete even though policy allowed them.", + )), + }, + ); + push_recommendation( + recommendations, + Recommendation { + priority: String::from("high"), + action: String::from("Inspect ado-proxy upstream connectivity"), + reason: String::from( + "AWF/Squid egress, CA trust, or Azure DevOps availability prevented an allowed request.", + ), + example: None, + }, + ); + } + + let out_of_scope_response = proxy_reason_count(proxy, &["out-of-scope-response"]); + if out_of_scope_response > 0 { + push_finding( + findings, + Finding { + category: String::from("security"), + severity: Severity::High, + title: String::from("ado-proxy blocked an over-broad upstream response"), + description: format!( + "Response filtering rejected {out_of_scope_response} response(s) containing resources outside the configured scope." + ), + impact: Some(String::from( + "The proxy prevented out-of-scope Azure DevOps data from reaching the agent.", + )), + }, + ); + push_recommendation( + recommendations, + Recommendation { + priority: String::from("high"), + action: String::from( + "Inspect the affected ado-proxy operation and response filter", + ), + reason: String::from( + "The response shape may have changed or the operation may require a tighter catalog filter; do not bypass response filtering.", + ), + example: None, + }, + ); + } + + let prompt_conflict_reasons = [ + "capability-disabled", + "out-of-scope", + "api-version", + "query-not-allowed", + ]; + let prompt_conflicts = proxy_reason_count(proxy, &prompt_conflict_reasons); + if prompt_conflicts > 0 { + push_finding( + findings, + Finding { + category: String::from("configuration"), + severity: Severity::Medium, + title: String::from("Agent requests conflicted with permissions.read"), + description: format!( + "{prompt_conflicts} request(s) were denied by configured capability, scope, API-version, or query limits: {}.", + format_proxy_reasons(proxy, &prompt_conflict_reasons) + ), + impact: None, + }, + ); + push_recommendation( + recommendations, + Recommendation { + priority: String::from("medium"), + action: String::from( + "Align the agent prompt with effective Azure DevOps permissions", + ), + reason: String::from( + "The prompt requested data outside the declared front-matter contract. Deliberately review front matter only when broader access is legitimate.", + ), + example: None, + }, + ); + } + + let prohibited_reasons = [ + "method-not-read", + "denied-route-family", + "unknown-route", + "unknown-host", + "malformed-target", + ]; + let prohibited = proxy_reason_count(proxy, &prohibited_reasons); + if prohibited > 0 { + push_finding( + findings, + Finding { + category: String::from("security"), + severity: Severity::Medium, + title: String::from("ado-proxy blocked prohibited request shapes"), + description: format!( + "{prohibited} direct write, denied-family, unknown, or malformed request(s) were blocked: {}.", + format_proxy_reasons(proxy, &prohibited_reasons) + ), + impact: None, + }, + ); + push_recommendation( + recommendations, + Recommendation { + priority: String::from("medium"), + action: String::from("Remove unsupported Azure DevOps requests from the prompt"), + reason: String::from( + "Direct writes and uncatalogued APIs must not be enabled by widening the proxy policy.", + ), + example: None, + }, + ); + } + + if proxy.malformed_record_count > 0 { + push_finding( + findings, + Finding { + category: String::from("ado_proxy"), + severity: Severity::Medium, + title: String::from("ado-proxy decision log contained malformed records"), + description: format!( + "{} decision record(s) did not match the declared v1 schema.", + proxy.malformed_record_count + ), + impact: Some(String::from( + "The audit summary may omit affected proxy decisions.", + )), + }, + ); + push_recommendation( + recommendations, + Recommendation { + priority: String::from("medium"), + action: String::from("Check ado-proxy bundle/compiler schema compatibility"), + reason: String::from( + "The analyzer rejected records rather than guessing at an unknown shape.", + ), + example: None, + }, + ); + } +} + +fn proxy_reason_count(proxy: &crate::audit::model::AdoProxyAnalysis, reasons: &[&str]) -> u64 { + proxy + .reasons + .iter() + .filter(|stat| reasons.contains(&stat.reason.as_str())) + .map(|stat| stat.count) + .sum() +} + +fn format_proxy_reasons(proxy: &crate::audit::model::AdoProxyAnalysis, reasons: &[&str]) -> String { + proxy + .reasons + .iter() + .filter(|stat| reasons.contains(&stat.reason.as_str())) + .take(5) + .map(|stat| format!("{} ({})", stat.reason, stat.count)) + .collect::>() + .join(", ") +} + fn add_elevated_mcp_error_rate( audit: &AuditData, findings: &mut Vec, @@ -475,9 +729,9 @@ fn push_recommendation(recommendations: &mut Vec, recommendation mod tests { use super::derive_findings; use crate::audit::model::{ - AuditData, DomainStat, Finding, FirewallAnalysis, JobData, MCPServerHealth, MCPServerStats, - MetricsData, MissingDataReport, MissingToolReport, NoopReport, Recommendation, - SafeOutputSummary, Severity, + AdoProxyAnalysis, AdoProxyLifecycle, AdoProxyReasonStat, AuditData, DomainStat, Finding, + FirewallAnalysis, JobData, MCPServerHealth, MCPServerStats, MetricsData, MissingDataReport, + MissingToolReport, NoopReport, Recommendation, SafeOutputSummary, Severity, }; fn finding_by_title<'a>(audit: &'a AuditData, title: &str) -> &'a Finding { @@ -506,6 +760,146 @@ mod tests { assert!(audit.recommendations.is_empty()); } + #[test] + fn unhealthy_proxy_lifecycle_emits_high_finding() { + let mut audit = AuditData { + ado_proxy_analysis: Some(AdoProxyAnalysis { + lifecycle: Some(AdoProxyLifecycle { + state_before_teardown: Some(String::from("missing")), + listening: false, + healthy_before_teardown: false, + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + + derive_findings(&mut audit); + + assert_eq!( + finding_by_title(&audit, "ado-proxy was not healthy before teardown").severity, + Severity::High + ); + assert_eq!( + recommendation_by_action(&audit, "Inspect ado-proxy lifecycle diagnostics").priority, + "high" + ); + } + + #[test] + fn proxy_operational_and_response_failures_are_elevated() { + let mut audit = AuditData { + ado_proxy_analysis: Some(AdoProxyAnalysis { + reasons: vec![ + AdoProxyReasonStat { + decision: String::from("error"), + reason: String::from("credential-unavailable"), + count: 1, + }, + AdoProxyReasonStat { + decision: String::from("error"), + reason: String::from("upstream-failed"), + count: 2, + }, + AdoProxyReasonStat { + decision: String::from("deny"), + reason: String::from("out-of-scope-response"), + count: 1, + }, + ], + ..Default::default() + }), + ..Default::default() + }; + + derive_findings(&mut audit); + + for title in [ + "ado-proxy credential was unavailable", + "ado-proxy could not reach Azure DevOps upstream", + "ado-proxy blocked an over-broad upstream response", + ] { + assert_eq!(finding_by_title(&audit, title).severity, Severity::High); + } + } + + #[test] + fn proxy_policy_denials_are_aggregated_without_becoming_audit_errors() { + let mut audit = AuditData { + ado_proxy_analysis: Some(AdoProxyAnalysis { + deny_count: 4, + reasons: vec![ + AdoProxyReasonStat { + decision: String::from("deny"), + reason: String::from("out-of-scope"), + count: 2, + }, + AdoProxyReasonStat { + decision: String::from("deny"), + reason: String::from("capability-disabled"), + count: 1, + }, + AdoProxyReasonStat { + decision: String::from("deny"), + reason: String::from("method-not-read"), + count: 1, + }, + ], + stripped_credentials: [("authorization".to_string(), 4)].into(), + ..Default::default() + }), + ..Default::default() + }; + + derive_findings(&mut audit); + derive_findings(&mut audit); + + assert_eq!(audit.metrics.error_count, 0); + assert_eq!( + finding_by_title(&audit, "Agent requests conflicted with permissions.read").severity, + Severity::Medium + ); + assert_eq!( + finding_by_title(&audit, "ado-proxy blocked prohibited request shapes").severity, + Severity::Medium + ); + assert_eq!( + audit + .key_findings + .iter() + .filter(|finding| { + finding.title == "Agent requests conflicted with permissions.read" + }) + .count(), + 1 + ); + assert!( + audit + .key_findings + .iter() + .all(|finding| !finding.title.contains("credential header")) + ); + } + + #[test] + fn malformed_proxy_records_emit_schema_finding() { + let mut audit = AuditData { + ado_proxy_analysis: Some(AdoProxyAnalysis { + malformed_record_count: 2, + ..Default::default() + }), + ..Default::default() + }; + + derive_findings(&mut audit); + + assert_eq!( + finding_by_title(&audit, "ado-proxy decision log contained malformed records").severity, + Severity::Medium + ); + } + #[test] fn detection_finding_is_preserved_without_duplication() { let detection_finding = Finding { diff --git a/src/audit/model.rs b/src/audit/model.rs index cdc2c5e73..2b9e6365a 100644 --- a/src/audit/model.rs +++ b/src/audit/model.rs @@ -64,6 +64,9 @@ pub struct AuditData { /// MCP server reliability and call health derived from gateway logs. #[serde(skip_serializing_if = "Option::is_none")] pub mcp_server_health: Option, + /// Azure DevOps proxy request and lifecycle diagnostics derived from the Agent artifact. + #[serde(skip_serializing_if = "Option::is_none")] + pub ado_proxy_analysis: Option, /// Optional typed-IR graph correlation for the pipeline source that produced this build. #[serde(skip_serializing_if = "Option::is_none")] pub pipeline_graph: Option, @@ -764,6 +767,177 @@ pub struct MCPServerStats { pub unreliable: bool, } +/// Credential-isolated Azure DevOps proxy diagnostics for the audited run. +/// +/// This section is derived from the sanitized decision JSONL and container +/// lifecycle files under `agent_outputs_/logs/ado-proxy`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AdoProxyAnalysis { + /// Decision-log schema version accepted by the analyzer. + #[serde(skip_serializing_if = "Option::is_none")] + pub schema_version: Option, + /// Container readiness and pre-teardown state, when lifecycle logs were present. + #[serde(skip_serializing_if = "Option::is_none")] + pub lifecycle: Option, + /// Total valid decision records. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub total_requests: u64, + /// Allowed request count. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub allow_count: u64, + /// Denied request count. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub deny_count: u64, + /// Proxy/infrastructure error count. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub error_count: u64, + /// Deterministic request rollups per catalog operation. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub operations: Vec, + /// Deterministic denial/error rollups per machine-readable reason. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reasons: Vec, + /// Upstream response status classes, such as `2xx` or `4xx`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub upstream_status_classes: BTreeMap, + /// Aggregate latency for records that carried latency data. + #[serde(skip_serializing_if = "Option::is_none")] + pub latency: Option, + /// Total response bytes recorded for allowed responses. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub response_bytes: u64, + /// Client credential-header names stripped by the proxy. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub stripped_credentials: BTreeMap, + /// Decision records rejected as malformed or schema-incompatible. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub malformed_record_count: u64, + /// Bounded recent deny/error summaries in original log order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub recent_problem_events: Vec, +} + +/// `ado-proxy` container lifecycle facts captured before pipeline teardown. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AdoProxyLifecycle { + /// Docker state observed before the teardown step stopped the container. + #[serde(skip_serializing_if = "Option::is_none")] + pub state_before_teardown: Option, + /// Docker exit code observed before teardown, when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code_before_teardown: Option, + /// Docker-reported error text, normalized and bounded. + #[serde(skip_serializing_if = "Option::is_none")] + pub docker_error: Option, + /// Whether the proxy emitted its listening/readiness marker. + #[serde(default)] + pub listening: bool, + /// Whether the observed pre-teardown lifecycle was healthy. + #[serde(default)] + pub healthy_before_teardown: bool, + /// Bounded recognized startup/runtime diagnostic messages. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +/// Aggregate decision statistics for one catalog operation. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AdoProxyOperationStat { + /// Catalog operation identifier; absent when no operation matched. + #[serde(skip_serializing_if = "Option::is_none")] + pub operation: Option, + /// Total request records for this operation. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub request_count: u64, + /// Allowed request count. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub allow_count: u64, + /// Denied request count. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub deny_count: u64, + /// Proxy/infrastructure error count. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub error_count: u64, + /// Aggregate latency for this operation. + #[serde(skip_serializing_if = "Option::is_none")] + pub latency: Option, + /// Total response bytes for this operation. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub response_bytes: u64, +} + +/// Aggregate decision statistics for one `(decision, reason)` pair. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AdoProxyReasonStat { + /// Machine-readable reason emitted by the proxy. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reason: String, + /// Decision class associated with the reason (`deny` or `error`). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub decision: String, + /// Number of records carrying this reason. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub count: u64, +} + +/// Aggregate latency statistics without retaining individual samples. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AdoProxyLatencyStats { + /// Number of records carrying latency data. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub observed_count: u64, + /// Sum of all observed latency values. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub total_ms: u64, + /// Arithmetic mean of observed latency values. + #[serde(default, skip_serializing_if = "is_zero_f64")] + pub average_ms: f64, + /// Maximum observed latency. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub max_ms: u64, +} + +/// Sanitized summary of one recent denied or failed proxy request. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AdoProxyEventSummary { + /// Event timestamp. + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + /// Proxy request correlation identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + /// Protected destination host. + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + /// HTTP method. + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + /// Catalog operation identifier, when matched. + #[serde(skip_serializing_if = "Option::is_none")] + pub operation: Option, + /// Decision class (`deny` or `error`). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub decision: String, + /// Machine-readable denial/error reason. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Bounded human-readable detail. + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// Upstream status class, when present. + #[serde(skip_serializing_if = "Option::is_none")] + pub upstream_status_class: Option, + /// Request latency in milliseconds, when present. + #[serde(skip_serializing_if = "Option::is_none")] + pub latency_ms: Option, +} + /// MCP-tool usage summary for the run. /// /// This section is derived from MCP gateway logs and summarizes calls per tool. diff --git a/src/audit/render/console.rs b/src/audit/render/console.rs index a3f714426..230a98b0f 100644 --- a/src/audit/render/console.rs +++ b/src/audit/render/console.rs @@ -28,6 +28,7 @@ pub fn render_console(audit: &crate::audit::model::AuditData) -> String { render_safe_output_summary_section(audit.safe_output_summary.as_ref()), render_rejected_safe_outputs_section(audit.rejected_safe_outputs.as_ref()), render_mcp_server_health_section(audit.mcp_server_health.as_ref()), + render_ado_proxy_analysis_section(audit.ado_proxy_analysis.as_ref()), render_firewall_analysis_section(audit.firewall_analysis.as_ref()), render_policy_analysis_section(audit.policy_analysis.as_ref()), render_detection_analysis_section(audit.detection_analysis.as_ref()), @@ -293,6 +294,145 @@ fn render_mcp_server_health_section(health: Option<&model::MCPServerHealth>) -> Some(render_lines_section("MCP Server Health", lines, false)) } +fn render_ado_proxy_analysis_section(analysis: Option<&model::AdoProxyAnalysis>) -> Option { + let analysis = analysis?; + let mut lines = Vec::new(); + + if let Some(schema_version) = analysis.schema_version.as_deref() { + lines.push(format!("- schema: {schema_version}")); + } + if let Some(lifecycle) = &analysis.lifecycle { + let mut lifecycle_line = format!( + "- lifecycle: {} (state before teardown: {}, listening: {})", + if lifecycle.healthy_before_teardown { + "healthy" + } else { + "unhealthy" + }, + lifecycle + .state_before_teardown + .as_deref() + .unwrap_or("unknown"), + if lifecycle.listening { "yes" } else { "no" }, + ); + if let Some(exit_code) = lifecycle.exit_code_before_teardown { + lifecycle_line.push_str(&format!(", exit code: {exit_code}")); + } + lines.push(lifecycle_line); + for diagnostic in &lifecycle.diagnostics { + lines.push(format!("- lifecycle diagnostic: {diagnostic}")); + } + if let Some(error) = &lifecycle.docker_error { + lines.push(format!("- Docker error: {error}")); + } + } + + if analysis.total_requests > 0 + || analysis.allow_count > 0 + || analysis.deny_count > 0 + || analysis.error_count > 0 + { + lines.push(format!( + "- requests: {} total, {} allowed, {} denied, {} errors", + format_number(analysis.total_requests), + format_number(analysis.allow_count), + format_number(analysis.deny_count), + format_number(analysis.error_count), + )); + } + if let Some(latency) = &analysis.latency { + lines.push(format!( + "- latency: {} observed, {} ms average, {} ms max", + format_number(latency.observed_count), + format_float(latency.average_ms), + format_number(latency.max_ms), + )); + } + if analysis.response_bytes > 0 { + lines.push(format!( + "- response bytes: {}", + format_number(analysis.response_bytes) + )); + } + if analysis.malformed_record_count > 0 { + lines.push(format!( + "- malformed records: {}", + format_number(analysis.malformed_record_count) + )); + } + if !analysis.upstream_status_classes.is_empty() { + lines.push(format!( + "- upstream status classes: {}", + analysis + .upstream_status_classes + .iter() + .map(|(class, count)| format!("{class}={}", format_number(*count))) + .collect::>() + .join(", ") + )); + } + if !analysis.stripped_credentials.is_empty() { + lines.push(format!( + "- stripped credential headers: {}", + analysis + .stripped_credentials + .iter() + .map(|(header, count)| format!("{header}={}", format_number(*count))) + .collect::>() + .join(", ") + )); + } + + if !analysis.operations.is_empty() { + lines.push(String::from("Operations")); + lines.extend(analysis.operations.iter().map(|operation| { + format!( + "- {} {} requests ({} allow, {} deny, {} error)", + operation.operation.as_deref().unwrap_or("(unmatched)"), + format_number(operation.request_count), + format_number(operation.allow_count), + format_number(operation.deny_count), + format_number(operation.error_count), + ) + })); + } + + if !analysis.reasons.is_empty() { + lines.push(String::from("Reasons")); + lines.extend(analysis.reasons.iter().map(|reason| { + format!( + "- {}/{} {}", + reason.decision, + reason.reason, + format_number(reason.count) + ) + })); + } + + if !analysis.recent_problem_events.is_empty() { + lines.push(String::from("Recent denied/error requests")); + lines.extend(analysis.recent_problem_events.iter().map(|event| { + let mut fields = vec![ + event.timestamp.as_deref().unwrap_or("(unknown time)"), + event.method.as_deref().unwrap_or("(unknown method)"), + event.host.as_deref().unwrap_or("(unknown host)"), + event.operation.as_deref().unwrap_or("(unmatched)"), + ]; + if let Some(reason) = event.reason.as_deref() { + fields.push(reason); + } + let mut line = format!("- {}", fields.join(" ")); + if let Some(detail) = event.detail.as_deref() { + line.push_str(": "); + line.push_str(detail); + } + line + })); + } + + (!lines.is_empty()).then(|| render_lines_section("ADO Proxy Analysis", lines, false)) +} + fn render_firewall_analysis_section(analysis: Option<&model::FirewallAnalysis>) -> Option { let analysis = analysis?; if analysis.domains.is_empty() @@ -1043,14 +1183,16 @@ fn fallback_text<'a>(value: &'a str, fallback: &'a str) -> &'a str { mod tests { use super::render_console; use crate::audit::model::{ - AgenticAssessment, AuditData, AuditEngineConfig, AwInfo, BehaviorFingerprint, - ComponentProvenance, CreatedItemReport, CustomSafeOutputAdoJob, CustomSafeOutputJobAudit, - DetectionAnalysis, DetectionThreats, DomainStat, ErrorInfo, FileInfo, Finding, - FirewallAnalysis, JobData, MCPFailureReport, MCPServerHealth, MCPServerStats, - MCPToolSummary, MCPToolUsageData, MetricsData, MissingDataReport, MissingToolReport, - NoopReport, PerformanceMetrics, PolicyAnalysis, PolicyRule, Recommendation, - RejectedSafeOutputsRollup, SafeOutputExecution, SafeOutputExecutionItem, SafeOutputStatus, - SafeOutputSummary, Severity, TaskDomainInfo, ToolUsageInfo, + AdoProxyAnalysis, AdoProxyEventSummary, AdoProxyLatencyStats, AdoProxyLifecycle, + AdoProxyOperationStat, AdoProxyReasonStat, AgenticAssessment, AuditData, AuditEngineConfig, + AwInfo, BehaviorFingerprint, ComponentProvenance, CreatedItemReport, + CustomSafeOutputAdoJob, CustomSafeOutputJobAudit, DetectionAnalysis, DetectionThreats, + DomainStat, ErrorInfo, FileInfo, Finding, FirewallAnalysis, JobData, MCPFailureReport, + MCPServerHealth, MCPServerStats, MCPToolSummary, MCPToolUsageData, MetricsData, + MissingDataReport, MissingToolReport, NoopReport, PerformanceMetrics, PolicyAnalysis, + PolicyRule, Recommendation, RejectedSafeOutputsRollup, SafeOutputExecution, + SafeOutputExecutionItem, SafeOutputStatus, SafeOutputSummary, Severity, TaskDomainInfo, + ToolUsageInfo, }; use serde_json::json; use std::collections::BTreeMap; @@ -1065,6 +1207,67 @@ mod tests { assert_eq!(headings, vec!["## Overview", "## Metrics"]); } + #[test] + fn ado_proxy_analysis_renders_lifecycle_rollups_and_recent_events() { + let audit = AuditData { + ado_proxy_analysis: Some(AdoProxyAnalysis { + schema_version: Some(String::from("ado-aw/ado-proxy-decisions/v1")), + lifecycle: Some(AdoProxyLifecycle { + state_before_teardown: Some(String::from("running")), + exit_code_before_teardown: Some(0), + listening: true, + healthy_before_teardown: true, + ..Default::default() + }), + total_requests: 3, + allow_count: 2, + deny_count: 1, + operations: vec![AdoProxyOperationStat { + operation: Some(String::from("core.project.get")), + request_count: 3, + allow_count: 2, + deny_count: 1, + ..Default::default() + }], + reasons: vec![AdoProxyReasonStat { + reason: String::from("out-of-scope"), + decision: String::from("deny"), + count: 1, + }], + latency: Some(AdoProxyLatencyStats { + observed_count: 2, + total_ms: 30, + average_ms: 15.0, + max_ms: 20, + }), + response_bytes: 1234, + stripped_credentials: [("authorization".to_string(), 3)].into(), + recent_problem_events: vec![AdoProxyEventSummary { + timestamp: Some(String::from("2026-01-01T00:00:00Z")), + host: Some(String::from("dev.azure.com")), + method: Some(String::from("GET")), + operation: Some(String::from("core.project.get")), + decision: String::from("deny"), + reason: Some(String::from("out-of-scope")), + detail: Some(String::from("project is outside the policy")), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }; + + let out = render_console(&audit); + + assert!(out.contains("## ADO Proxy Analysis")); + assert!(out.contains("lifecycle: healthy")); + assert!(out.contains("3 total, 2 allowed, 1 denied, 0 errors")); + assert!(out.contains("15 ms average")); + assert!(out.contains("core.project.get 3 requests")); + assert!(out.contains("deny/out-of-scope 1")); + assert!(out.contains("project is outside the policy")); + } + #[test] fn full_audit_data_renders_inline_snapshot() { let audit = populated_audit_data(); diff --git a/src/audit/render/json.rs b/src/audit/render/json.rs index 013d8390d..c581b672b 100644 --- a/src/audit/render/json.rs +++ b/src/audit/render/json.rs @@ -317,4 +317,36 @@ mod tests { keys.sort(); assert_eq!(keys, vec!["downloaded_files", "metrics", "overview"]); } + + #[test] + fn ado_proxy_analysis_round_trips_as_optional_public_json() { + let original = AuditData { + ado_proxy_analysis: Some(AdoProxyAnalysis { + schema_version: Some(String::from("ado-aw/ado-proxy-decisions/v1")), + total_requests: 2, + allow_count: 1, + deny_count: 1, + reasons: vec![AdoProxyReasonStat { + reason: String::from("out-of-scope"), + decision: String::from("deny"), + count: 1, + }], + ..Default::default() + }), + ..Default::default() + }; + let rendered = render_json_to_string(&original).expect("render proxy analysis"); + let value: Value = serde_json::from_str(&rendered).expect("parse proxy JSON"); + + assert_eq!( + value["ado_proxy_analysis"]["schema_version"], + "ado-aw/ado-proxy-decisions/v1" + ); + assert_eq!(value["ado_proxy_analysis"]["total_requests"], 2); + assert!(value["ado_proxy_analysis"].get("raw_url").is_none()); + + let round_tripped: AuditData = + serde_json::from_str(&rendered).expect("deserialize proxy analysis"); + assert_eq!(round_tripped, original); + } } diff --git a/src/inspect/trace.rs b/src/inspect/trace.rs index 22a7f3351..5a36331e3 100644 --- a/src/inspect/trace.rs +++ b/src/inspect/trace.rs @@ -4,7 +4,7 @@ use std::collections::BTreeSet; use serde::Serialize; -use crate::audit::model::{AuditData, JobData}; +use crate::audit::model::{AdoProxyEventSummary, AdoProxyReasonStat, AuditData, JobData}; use crate::compile::ir::summary::StepLocationEntry; use crate::inspect::graph_deps::{self, GraphDepsDirection, StepDependency}; @@ -14,9 +14,27 @@ pub struct TraceReport { #[serde(skip_serializing_if = "Vec::is_empty", default)] pub failing_jobs: Vec, #[serde(skip_serializing_if = "Option::is_none")] + pub ado_proxy: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub step: Option, } +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TraceAdoProxySummary { + #[serde(skip_serializing_if = "Option::is_none")] + pub healthy_before_teardown: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub state_before_teardown: Option, + pub total_requests: u64, + pub allow_count: u64, + pub deny_count: u64, + pub error_count: u64, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub top_reasons: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub recent_problem_events: Vec, +} + #[derive(Debug, Clone, PartialEq, Serialize)] pub struct TraceJobReport { pub job: String, @@ -72,10 +90,30 @@ pub fn build_trace_report(audit: &AuditData, step: Option<&str>) -> TraceReport .collect(); let step_report = step.and_then(|step_id| build_step_report(audit, step_id)); + let ado_proxy = audit.ado_proxy_analysis.as_ref().map(|analysis| { + let recent_start = analysis.recent_problem_events.len().saturating_sub(5); + TraceAdoProxySummary { + healthy_before_teardown: analysis + .lifecycle + .as_ref() + .map(|lifecycle| lifecycle.healthy_before_teardown), + state_before_teardown: analysis + .lifecycle + .as_ref() + .and_then(|lifecycle| lifecycle.state_before_teardown.clone()), + total_requests: analysis.total_requests, + allow_count: analysis.allow_count, + deny_count: analysis.deny_count, + error_count: analysis.error_count, + top_reasons: analysis.reasons.iter().take(5).cloned().collect(), + recent_problem_events: analysis.recent_problem_events[recent_start..].to_vec(), + } + }); TraceReport { build_id: audit.overview.build_id, failing_jobs, + ado_proxy, step: step_report, } } @@ -102,6 +140,53 @@ pub fn render_text( } } + if let Some(proxy) = &report.ado_proxy { + out.push('\n'); + out.push_str("ADO proxy diagnostics\n"); + if let Some(healthy) = proxy.healthy_before_teardown { + out.push_str(&format!( + " lifecycle: {}", + if healthy { "healthy" } else { "unhealthy" } + )); + if let Some(state) = proxy.state_before_teardown.as_deref() { + out.push_str(&format!(" (state before teardown: {state})")); + } + out.push('\n'); + } + out.push_str(&format!( + " requests: {} total, {} allowed, {} denied, {} errors\n", + proxy.total_requests, proxy.allow_count, proxy.deny_count, proxy.error_count + )); + if !proxy.top_reasons.is_empty() { + out.push_str(&format!( + " top reasons: {}\n", + proxy + .top_reasons + .iter() + .map(|reason| { + format!("{}/{} ({})", reason.decision, reason.reason, reason.count) + }) + .collect::>() + .join(", ") + )); + } + for event in &proxy.recent_problem_events { + out.push_str(&format!( + " - {} {} {} {} [{}]{}\n", + event.timestamp.as_deref().unwrap_or("(unknown time)"), + event.method.as_deref().unwrap_or("(unknown method)"), + event.host.as_deref().unwrap_or("(unknown host)"), + event.operation.as_deref().unwrap_or("(unmatched)"), + event.reason.as_deref().unwrap_or(&event.decision), + event + .detail + .as_deref() + .map(|detail| format!(": {detail}")) + .unwrap_or_default() + )); + } + } + if requested_step.is_some() { out.push('\n'); out.push_str("Step trace\n"); @@ -344,7 +429,10 @@ fn job_status(job: &JobData) -> String { #[cfg(test)] mod tests { use super::*; - use crate::audit::model::{AuditData, OverviewData}; + use crate::audit::model::{ + AdoProxyAnalysis, AdoProxyEventSummary, AdoProxyLifecycle, AdoProxyReasonStat, AuditData, + OverviewData, + }; #[test] fn build_trace_report_shapes_failed_job_chain_without_network() { @@ -394,4 +482,73 @@ mod tests { "expected to skip" ); } + + #[test] + fn trace_projects_bounded_run_level_proxy_diagnostics() { + let reasons = (0..7) + .map(|index| AdoProxyReasonStat { + reason: format!("reason-{index}"), + decision: String::from("deny"), + count: 7 - index, + }) + .collect(); + let events = (0..7) + .map(|index| AdoProxyEventSummary { + request_id: Some(index.to_string()), + method: Some(String::from("GET")), + operation: Some(String::from("core.project.get")), + decision: String::from("deny"), + reason: Some(String::from("out-of-scope")), + ..Default::default() + }) + .collect(); + let audit = AuditData { + overview: OverviewData { + build_id: 42, + ..Default::default() + }, + ado_proxy_analysis: Some(AdoProxyAnalysis { + lifecycle: Some(AdoProxyLifecycle { + state_before_teardown: Some(String::from("running")), + healthy_before_teardown: true, + ..Default::default() + }), + total_requests: 10, + allow_count: 3, + deny_count: 7, + reasons, + recent_problem_events: events, + ..Default::default() + }), + ..Default::default() + }; + + let report = build_trace_report(&audit, None); + let proxy = report.ado_proxy.as_ref().expect("proxy trace summary"); + assert_eq!(proxy.top_reasons.len(), 5); + assert_eq!(proxy.recent_problem_events.len(), 5); + assert_eq!( + proxy.recent_problem_events[0].request_id.as_deref(), + Some("2") + ); + + let rendered = render_text(&audit, &report, None); + assert!(rendered.contains("ADO proxy diagnostics")); + assert!(rendered.contains("10 total, 3 allowed, 7 denied, 0 errors")); + } + + #[test] + fn trace_omits_proxy_section_when_analysis_is_absent() { + let audit = AuditData { + overview: OverviewData { + build_id: 42, + ..Default::default() + }, + ..Default::default() + }; + let report = build_trace_report(&audit, None); + + assert!(report.ado_proxy.is_none()); + assert!(!render_text(&audit, &report, None).contains("ADO proxy diagnostics")); + } } diff --git a/src/mcp_author/tests.rs b/src/mcp_author/tests.rs index 280434fd3..a16399772 100644 --- a/src/mcp_author/tests.rs +++ b/src/mcp_author/tests.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use rmcp::handler::server::wrapper::Parameters; use super::*; +use crate::audit::model::{AdoProxyAnalysis, AdoProxyReasonStat, AuditData}; use crate::compile::ir::summary::{GraphSummary, PipelineSummary}; use crate::inspect::lint::LintReport; @@ -42,6 +43,32 @@ fn list_tools_contains_expected_author_surface() { } } +#[test] +fn structured_audit_result_preserves_ado_proxy_analysis() { + let result = structured_result(AuditData { + ado_proxy_analysis: Some(AdoProxyAnalysis { + total_requests: 2, + deny_count: 1, + reasons: vec![AdoProxyReasonStat { + reason: String::from("out-of-scope"), + decision: String::from("deny"), + count: 1, + }], + ..Default::default() + }), + ..Default::default() + }) + .expect("serialize structured audit result"); + + let audit = result + .into_typed::() + .expect("structured result contains AuditData"); + assert_eq!( + audit.ado_proxy_analysis.expect("proxy analysis").deny_count, + 1 + ); +} + #[tokio::test] async fn inspect_workflow_returns_pipeline_summary_schema_version_one() { let server = AuthorMcp::new(); From e5e82799c590e7d8a2295dcb14241b64c14df6ee Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 5 Aug 2026 21:34:15 +0100 Subject: [PATCH 40/42] docs(audit): document ado-proxy diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- docs/ado-proxy-design.md | 6 +++++ docs/audit.md | 48 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/ado-proxy-design.md b/docs/ado-proxy-design.md index 8965bf00f..c268f7a5e 100644 --- a/docs/ado-proxy-design.md +++ b/docs/ado-proxy-design.md @@ -423,6 +423,12 @@ Operational diagnostics are equally deliberate: - teardown captures Docker lifecycle state/stdout, while sanitized decision JSONL and lifecycle logs are copied into `agent_outputs_/logs/ado-proxy`; +- `ado-aw audit` strictly reads the v1 decision stream plus + `container.log`/`container-state.txt` into an optional + `ado_proxy_analysis` section. It reports bounded operation/reason rollups, + recent deny/error events, and pre-teardown health without preserving raw + request content. `ado-aw trace` and the MCP-author audit/trace tools inherit + full or compact forms of the same diagnostics; - the generated agent prompt lists effective capabilities and scopes from the same front matter that produced the policy document, so predictable prompt/config conflicts are visible before the agent attempts an impossible diff --git a/docs/audit.md b/docs/audit.md index 74553515c..e2fdf6980 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -4,7 +4,7 @@ _Part of the [ado-aw documentation](../AGENTS.md)._ ## Overview -`ado-aw audit` audits one Azure DevOps build at a time. It downloads the selected build artifacts, runs the built-in analyzers (firewall, MCP gateway, OTel, safe outputs, detection verdict, build timeline, and missing-tool / missing-data / noop extraction), and renders a structured console report or the raw `AuditData` JSON. The MVP is single-run only; diff mode and cross-run trend reporting are follow-ups. +`ado-aw audit` audits one Azure DevOps build at a time. It downloads the selected build artifacts, runs the built-in analyzers (ADO proxy, firewall, MCP gateway, OTel, safe outputs, detection verdict, build timeline, and missing-tool / missing-data / noop extraction), and renders a structured console report or the raw `AuditData` JSON. The MVP is single-run only; diff mode and cross-run trend reporting are follow-ups. ## Usage @@ -53,6 +53,10 @@ URL-encoded project segments are decoded before the ADO context is resolved. `t= │ │ ├── aw_info.json # Runtime engine / agent / source metadata │ │ └── otel.jsonl # Copilot OTel (when emitted) │ └── logs/ +│ ├── ado-proxy/ # Sanitized ADO policy decisions + container lifecycle +│ │ ├── ado-proxy-decisions.jsonl +│ │ ├── container.log +│ │ └── container-state.txt │ ├── firewall/ # AWF Squid proxy logs │ ├── mcpg/ # MCP Gateway logs (includes the SafeOutputs stdio child's stdout/stderr) │ └── agent-output.txt # Filtered agent stdout @@ -85,6 +89,7 @@ Current top-level keys include the following. Optional sections are omitted from | `rejected_safe_outputs` | Rollup of rejections by reason / threat flag. | | `detection_analysis` | `threat-analysis.json`. | | `mcp_server_health` | MCPG logs aggregated per server. | +| `ado_proxy_analysis` | Sanitized `logs/ado-proxy` decisions and pre-teardown container lifecycle, including operation/reason rollups and bounded recent deny/error events. | | `pipeline_graph` | Optional typed-IR `PipelineSummary` rebuilt from local source metadata (`aw_info.json.source`) for graph correlation. | | `mcp_tool_usage` | MCPG logs aggregated per `(server, tool)`. | | `mcp_failures` | MCPG `tool_error` / `server_error` events. | @@ -97,6 +102,47 @@ Current top-level keys include the following. Optional sections are omitted from | `tool_usage` | High-level runtime tool-usage rollups derived from telemetry. | | `created_items` | Successful `executed` items with extracted id / url / title. | +## ADO proxy diagnostics + +Proxy-enabled Agent jobs publish three diagnostic files under +`agent_outputs_/logs/ado-proxy`. They are part of the existing +`agent` artifact family, so use `--artifacts agent` to fetch them; there is no +separate proxy artifact selector. + +The decision stream is schema-versioned as +`ado-aw/ado-proxy-decisions/v1`. Audit reads it strictly and reports: + +- allow, deny, and proxy-error totals; +- per-operation and per-reason counts; +- upstream status classes, latency, response bytes, and stripped + credential-header names; +- at most 20 recent denied/error request summaries; +- malformed-record counts without echoing malformed source lines. + +The stream contains no raw request path, query value, header value, body, +credential, or exact upstream status code. Audit likewise does not preserve +unknown JSON fields or raw log lines. + +`container.log` and `container-state.txt` provide readiness and state observed +immediately before teardown. The audit calls this +`state_before_teardown`—the file is captured before the pipeline stops the +container, so it is not a post-stop final state. + +Policy denials are diagnostic evidence that the boundary worked; they do not +change the Azure DevOps build result or increment the audit error count. +Lifecycle failures, unavailable credentials, upstream failures, and +out-of-scope response filtering produce elevated findings. Capability/scope +conflicts produce recommendations to align the prompt with front matter, not +to widen permissions automatically. + +`ado-aw trace` includes a compact run-level proxy summary. MCP-author +`audit_build` returns the full `ado_proxy_analysis`, while `trace_failure` +returns the compact trace projection. Proxy events are not attributed to a +specific job step because the v1 stream has no step identifier. + +When testing a newly-built audit analyzer against an already-cached build, pass +`--no-cache` so the downloaded artifact is reprocessed. + ## Rejected safe-output trace When `threat-analysis.json` reports any threat flag, the audit treats the SafeOutputs batch as rejected by the aggregate gate and records each proposal with: From 57aed6560dcae7efbd8a3f72f97113dd8a1e9a4b Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 5 Aug 2026 22:04:37 +0100 Subject: [PATCH 41/42] fix(audit): handle real artifact download layouts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- src/ado/mod.rs | 2 +- src/audit/cli.rs | 18 +-------- src/audit/mod.rs | 76 ++++++++++++++++++++++++++++++++++++- src/audit/pipeline_graph.rs | 16 +------- 4 files changed, 78 insertions(+), 34 deletions(-) diff --git a/src/ado/mod.rs b/src/ado/mod.rs index 6d1ee607c..89a4e265c 100644 --- a/src/ado/mod.rs +++ b/src/ado/mod.rs @@ -976,7 +976,7 @@ pub async fn resolve_auth(pat: Option<&str>) -> Result { info!("No PAT provided, trying Azure CLI authentication..."); match try_azure_cli_token().await { Ok(token) => { - println!("Using Azure CLI authentication (az account get-access-token)"); + eprintln!("Using Azure CLI authentication (az account get-access-token)"); Ok(AdoAuth::Bearer(token)) } Err(e) => { diff --git a/src/audit/cli.rs b/src/audit/cli.rs index 37b00faa8..3d9d9386e 100644 --- a/src/audit/cli.rs +++ b/src/audit/cli.rs @@ -13,6 +13,7 @@ use crate::audit::analyzers::{ ado_proxy, custom_jobs, detection, firewall, jobs, mcp, missing, otel, policy, safe_outputs, }; use crate::audit::cache::{RunSummary, load_run_summary, save_run_summary}; +use crate::audit::find_artifact_dir; use crate::audit::findings; use crate::audit::model::{AuditData, ErrorInfo, FileInfo, OverviewData}; use crate::audit::pipeline_graph; @@ -972,23 +973,6 @@ async fn collect_files_under(run_dir: &Path, start_dir: &Path) -> Result Option { - let mut entries = tokio::fs::read_dir(run_dir).await.ok()?; - let mut hits: Vec<(String, PathBuf)> = Vec::new(); - while let Ok(Some(entry)) = entries.next_entry().await { - if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) - && let Some(name) = entry.file_name().to_str() - && (name == prefix || name.starts_with(&format!("{}_", prefix))) - { - hits.push((name.to_string(), entry.path())); - } - } - // Numeric-suffix sort so `agent_outputs_10` outranks - // `agent_outputs_9` (lexicographic sort gets this wrong). - hits.sort_by(|(a, _), (b, _)| crate::audit::cmp_numeric_suffix(a, b)); - hits.pop().map(|(_, path)| path) -} - fn is_authz_error(error: &anyhow::Error) -> bool { let message = error.to_string().to_ascii_lowercase(); message.contains("ado api returned 401") || message.contains("ado api returned 403") diff --git a/src/audit/mod.rs b/src/audit/mod.rs index 254024338..bae5429cf 100644 --- a/src/audit/mod.rs +++ b/src/audit/mod.rs @@ -1,3 +1,5 @@ +use std::path::{Path, PathBuf}; + /// Shared audit data types for `ado-aw audit`. /// /// This module defines the public report model that analyzers populate and renderers @@ -51,9 +53,51 @@ pub(crate) fn cmp_numeric_suffix(a: &str, b: &str) -> std::cmp::Ordering { suffix(a).cmp(&suffix(b)).then_with(|| a.cmp(b)) } +/// Resolve the newest local artifact directory for `prefix`. +/// +/// ADO PipelineArtifact zip downloads may contain a top-level directory whose +/// name repeats the artifact name. The downloader already creates an outer +/// `/` extraction directory, producing +/// `///...`. When that repeated directory is +/// the outer directory's only entry, return the inner content root so every +/// analyzer sees the same layout as a non-wrapped artifact or manual download. +pub(crate) async fn find_artifact_dir(run_dir: &Path, prefix: &str) -> Option { + let mut entries = tokio::fs::read_dir(run_dir).await.ok()?; + let mut hits: Vec<(String, PathBuf)> = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) + && let Some(name) = entry.file_name().to_str() + && (name == prefix || name.starts_with(&format!("{prefix}_"))) + { + hits.push((name.to_string(), entry.path())); + } + } + hits.sort_by(|(a, _), (b, _)| cmp_numeric_suffix(a, b)); + let (name, outer) = hits.pop()?; + + let mut outer_entries = match tokio::fs::read_dir(&outer).await { + Ok(entries) => entries, + Err(_) => return Some(outer), + }; + let first = match outer_entries.next_entry().await { + Ok(Some(entry)) => entry, + Ok(None) | Err(_) => return Some(outer), + }; + let has_no_sibling = matches!(outer_entries.next_entry().await, Ok(None)); + let first_is_dir = first + .file_type() + .await + .map(|file_type| file_type.is_dir()) + .unwrap_or(false); + if has_no_sibling && first_is_dir && first.file_name().to_str() == Some(name.as_str()) { + return Some(first.path()); + } + Some(outer) +} + #[cfg(test)] mod numeric_suffix_tests { - use super::cmp_numeric_suffix; + use super::{cmp_numeric_suffix, find_artifact_dir}; use std::cmp::Ordering; #[test] @@ -87,4 +131,34 @@ mod numeric_suffix_tests { Ordering::Less ); } + + #[tokio::test] + async fn artifact_dir_unwraps_a_single_redundant_named_root() { + let temp = tempfile::tempdir().unwrap(); + let outer = temp.path().join("agent_outputs_42"); + let inner = outer.join("agent_outputs_42"); + tokio::fs::create_dir_all(&inner).await.unwrap(); + + assert_eq!( + find_artifact_dir(temp.path(), "agent_outputs").await, + Some(inner) + ); + } + + #[tokio::test] + async fn artifact_dir_keeps_outer_root_when_it_has_other_entries() { + let temp = tempfile::tempdir().unwrap(); + let outer = temp.path().join("agent_outputs_42"); + tokio::fs::create_dir_all(outer.join("agent_outputs_42")) + .await + .unwrap(); + tokio::fs::write(outer.join("aw_info.json"), "{}") + .await + .unwrap(); + + assert_eq!( + find_artifact_dir(temp.path(), "agent_outputs").await, + Some(outer) + ); + } } diff --git a/src/audit/pipeline_graph.rs b/src/audit/pipeline_graph.rs index d7a01e54f..7a0a02e25 100644 --- a/src/audit/pipeline_graph.rs +++ b/src/audit/pipeline_graph.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use log::warn; +use crate::audit::find_artifact_dir; use crate::audit::model::{AuditData, AwInfo, ErrorInfo, PipelineGraphSection}; use crate::compile::ir::summary::{JobSummary, PipelineSummary}; @@ -197,21 +198,6 @@ async fn resolve_source_path(source: &str) -> Result { Ok(validated.path) } -async fn find_artifact_dir(run_dir: &Path, prefix: &str) -> Option { - let mut entries = tokio::fs::read_dir(run_dir).await.ok()?; - let mut hits: Vec<(String, PathBuf)> = Vec::new(); - while let Ok(Some(entry)) = entries.next_entry().await { - if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) - && let Some(name) = entry.file_name().to_str() - && (name == prefix || name.starts_with(&format!("{prefix}_"))) - { - hits.push((name.to_string(), entry.path())); - } - } - hits.sort_by(|(a, _), (b, _)| crate::audit::cmp_numeric_suffix(a, b)); - hits.pop().map(|(_, path)| path) -} - fn record_warning(audit: &mut AuditData, source: &str, message: impl Into) { crate::audit::push_warning_once( audit, From 38d6885b91bc6e6449654c0d89ab146ec7b99b16 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 6 Aug 2026 13:39:14 +0100 Subject: [PATCH 42/42] refactor(compile): separate ADO org script from YAML layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa672954-b2fc-4c5d-82e5-5b066b8e0af8 --- src/compile/agentic_pipeline.rs | 2 +- src/compile/common.rs | 52 +++++++++++++++------------------ src/compile/types.rs | 6 ++-- src/engine.rs | 5 +++- 4 files changed, 32 insertions(+), 33 deletions(-) diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 51408ae28..9abbb39e3 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -4381,7 +4381,7 @@ fn start_ado_proxy_step(front_matter: &FrontMatter) -> BashStep { echo \"ado-proxy is ready at $ADO_PROXY_IP\"\n\ docker logs --tail 1 {ADO_PROXY_CONTAINER_NAME} 2>&1 || true\n\ echo \"##vso[task.setvariable variable=ADO_PROXY_IP]$ADO_PROXY_IP\"\n", - org_resolve = common::resolve_ado_organization_bash(" "), + org_resolve = common::resolve_ado_organization_bash(), ado_proxy_path = paths::ADO_PROXY_PATH, ca_host_path = ADO_PROXY_PUBLIC_CA_HOST_PATH, az_wrapper_dir = AZ_WRAPPER_DIR, diff --git a/src/compile/common.rs b/src/compile/common.rs index a9313f940..fd8adf8bb 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1784,9 +1784,6 @@ pub const ADO_MCP_CA_MOUNT: &str = "/etc/ado-proxy/ca.pem"; /// Bash that derives the Azure DevOps organization name from /// `$(System.CollectionUri)` into `$ADO_PROXY_ORGANIZATION`. /// -/// `indent` is the leading whitespace each emitted line needs, so the same -/// helper can be dropped into differently-indented bodies. -/// /// One implementation on purpose, because the two it replaced were both wrong /// for a form the other handled. `engine.rs` stripped a literal /// `https://dev.azure.com/` prefix, a no-op for `https://myorg.visualstudio.com/` @@ -1799,22 +1796,21 @@ pub const ADO_MCP_CA_MOUNT: &str = "/etc/ado-proxy/ca.pem"; /// is. Getting this wrong is not cosmetic — in a policy document a wrong /// organization matches nothing, denying every request in a way that reads as /// a deliberate policy decision. -pub fn resolve_ado_organization_bash(indent: &str) -> String { - format!( - "{indent}# $(System.CollectionUri) is expanded by ADO before bash runs. Two\n\ - {indent}# shapes are in use: \"https://dev.azure.com/myorg/\" (organization in\n\ - {indent}# the path) and the legacy \"https://myorg.visualstudio.com/\"\n\ - {indent}# (organization in the host). Handle both — a fixed-prefix strip or a\n\ - {indent}# bare last-segment rule is silently wrong for one of them.\n\ - {indent}ADO_PROXY_COLLECTION=\"$(System.CollectionUri)\"\n\ - {indent}ADO_PROXY_ORGANIZATION=$(printf '%s' \"$ADO_PROXY_COLLECTION\" \\\n\ - {indent} | sed -e 's#^https\\?://##' -e 's#/*$##' \\\n\ - {indent} | awk -F/ '{{ if (NF>1) print $NF; else {{ sub(/\\..*$/, \"\", $1); print $1 }} }}')\n\ - {indent}if [ -z \"$ADO_PROXY_ORGANIZATION\" ]; then\n\ - {indent} echo \"##vso[task.complete result=Failed]cannot determine the Azure DevOps organization from System.CollectionUri\"\n\ - {indent} exit 1\n\ - {indent}fi\n" - ) +pub fn resolve_ado_organization_bash() -> String { + "# $(System.CollectionUri) is expanded by ADO before bash runs. Two\n\ + # shapes are in use: \"https://dev.azure.com/myorg/\" (organization in\n\ + # the path) and the legacy \"https://myorg.visualstudio.com/\"\n\ + # (organization in the host). Handle both — a fixed-prefix strip or a\n\ + # bare last-segment rule is silently wrong for one of them.\n\ + ADO_PROXY_COLLECTION=\"$(System.CollectionUri)\"\n\ + ADO_PROXY_ORGANIZATION=$(printf '%s' \"$ADO_PROXY_COLLECTION\" \\\n\ + | sed -e 's#^https\\?://##' -e 's#/*$##' \\\n\ + | awk -F/ '{ if (NF>1) print $NF; else { sub(/\\..*$/, \"\", $1); print $1 } }')\n\ + if [ -z \"$ADO_PROXY_ORGANIZATION\" ]; then\n\ + echo \"##vso[task.complete result=Failed]cannot determine the Azure DevOps organization from System.CollectionUri\"\n\ + exit 1\n\ + fi\n" + .to_string() } /// Whether this workflow routes Azure DevOps access through the policy engine. @@ -5930,7 +5926,7 @@ safe-outputs: // `https://myorg.visualstudio.com/`, while a bare last-path-segment // rule returns `myorg.visualstudio.com` for that same URL. Measured // against both shapes plus an on-prem collection. - let script = resolve_ado_organization_bash(""); + let script = resolve_ado_organization_bash(); assert!( !script.contains("#https://dev.azure.com/"), "must not strip a fixed prefix: {script}" @@ -5950,14 +5946,14 @@ safe-outputs: } #[test] - fn resolve_ado_organization_indents_every_line() { - let script = resolve_ado_organization_bash(" "); - for line in script.lines().filter(|line| !line.is_empty()) { - assert!( - line.starts_with(" "), - "every line must carry the requested indent: {line:?}" - ); - } + fn resolve_ado_organization_has_no_yaml_layout_concerns() { + let script = resolve_ado_organization_bash(); + assert!(script.starts_with("# $(System.CollectionUri)")); + assert!( + script + .lines() + .any(|line| line.starts_with("ADO_PROXY_COLLECTION=")) + ); } #[test] diff --git a/src/compile/types.rs b/src/compile/types.rs index 22ebe637d..6ec4ce245 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -2847,9 +2847,9 @@ impl AdoReadCapability { /// Every capability an author may name in front matter. /// - /// Consumed by the drift guard that keeps this enum aligned with the - /// catalog; the policy-document emitter will be its second caller. - #[allow(dead_code)] + /// Test-only drift guards consume this list to keep the front-matter enum + /// aligned with the authoritative runtime catalog. + #[cfg(test)] pub const ALL: &'static [Self] = &[ Self::Core, Self::Repositories, diff --git a/src/engine.rs b/src/engine.rs index 4ee23527b..fc68a6793 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1105,7 +1105,10 @@ fn copilot_install_steps( // previous local implementation stripped a literal // `https://dev.azure.com/` prefix, which is a no-op for a // `*.visualstudio.com` or on-prem collection URL. - let resolve = crate::compile::resolve_ado_organization_bash(" "); + let resolve = crate::compile::resolve_ado_organization_bash() + .lines() + .map(|line| format!(" {line}\n")) + .collect::(); let step = format!( "\ - bash: |