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 bf9b263ff..3f3727e2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,9 +31,11 @@ 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 `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 configure or explicitly disable AI analysis under @@ -100,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 @@ -148,6 +150,10 @@ 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) +│ │ └── 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 @@ -288,6 +294,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/ # Smoke E2E orchestrator (not a bundle): stages each case in `tests/smoke/cases.json` to the fixed `.smoke/pipeline.yml` path on its own per-case `ado-aw-mirror` ref, queues it against its credential *lane* definition, and asserts they go green. Two modes via `SMOKE_COMPILER_SOURCE`: `candidate` (compiler built from this commit, pinned pipeline-artifact) and `released` (latest release asset, release URLs required). 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 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 @@ -407,6 +414,9 @@ index to jump to the right page. allowed domains, ecosystem identifiers, blocking, repository-resource `endpoint:` service connections, 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. @@ -482,6 +492,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 3d947644f..df0c07521 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,47 @@ 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** | 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 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 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 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 + `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 **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 +236,12 @@ sees. #### Permission Combinations -| Configuration | Agent can read ADO? | Safe outputs can write? | +| Configuration | Scoped Stage 1 ADO reads work? | 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/ado-proxy-design.md b/docs/ado-proxy-design.md new file mode 100644 index 000000000..c268f7a5e --- /dev/null +++ b/docs/ado-proxy-design.md @@ -0,0 +1,544 @@ +# 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, and the catalog +reports `runtime_available: true`._ + +## 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 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 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; +- 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 the host-task `System.AccessToken` used to + mint the one-shot ADO token; +- every Azure DevOps REST bearer minted from that identity; +- 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 +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. + +**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`) | 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` 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, +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. + +## Authentication and TLS + +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. + +### 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. + +### Why not install the CA container-wide + +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. + +### 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 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 versioned JSON document: + +```sh +# 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 +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 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. + + + +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 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`. + +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; +`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 delivery + +`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 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 + +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, 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 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. +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. 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 +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 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 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 `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. + +> **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 +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 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, + 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. + +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 + 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`; +- `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 + request. Runtime denials still return the machine-readable policy reason. + +## 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` | +| **`--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 | +| **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 | +| **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 | +| **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 | +| **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: + +- `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. 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 + +These gate implementation and are unresolved at the time of writing: + +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: + +- 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; +- 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 required sidecar and network + wiring, and internal mirrors contain that image. diff --git a/docs/ado-script.md b/docs/ado-script.md index a488adae0..94deef52d 100644 --- a/docs/ado-script.md +++ b/docs/ado-script.md @@ -442,6 +442,63 @@ 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. 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. + +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 / 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` | 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 organization-relative scope index. | +| `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/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: diff --git a/docs/cli.md b/docs/cli.md index 3de37e394..41406d84c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -174,9 +174,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 @@ -191,6 +192,19 @@ 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:`). 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. + ## 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/front-matter.md b/docs/front-matter.md index 8c053ce24..8a3fd1e6e 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: octo/templates # object form for an external repository type: github # external repo resource type; default is git ref: refs/heads/release/2.x checkout: false # declared as resource only, not checked out by the agent @@ -253,7 +253,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 service connection for read-only ADO access (Stage 1 agent) + 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 6087fa59c..f6f4336bb 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. +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 c8bb4557e..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. +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. -## Always-on Azure CLI (`az`) +## Proxy-gated 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*. +`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 @@ -92,9 +105,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` autoauthed via `$AZURE_DEVOPS_EXT_PAT`, ARM and Graph -requiring separate auth), and the fallback path (`missing-tool` -safe output naming `azure-cli`). +(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. @@ -224,9 +239,12 @@ See [`imports:`](imports.md) for the ADO-first compile-time `repository` and ## 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 | | ----------------------------------- | --------------------------------------------- | ----------------------------------------------- | @@ -258,7 +276,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 # trusted ado-proxy token source # write: my-write-arm-connection # Optional — see below ``` @@ -278,9 +296,49 @@ 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 + `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. + + 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: + 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 + project-id: 33333333-3333-3333-3333-333333333333 # optional + repositories: [other-repo] # omit for project-scoped reads only + ``` + + `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 @@ -292,7 +350,7 @@ agents. Set `permissions.write` only when you need: ### Examples ```yaml -# Default: agent can read ADO, 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 diff --git a/docs/tools.md b/docs/tools.md index 56bfadae0..f818c91cb 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,27 +81,35 @@ 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:** `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. +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 - -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: @@ -122,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 @@ -140,24 +149,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/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/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..1261d0ef2 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,13 +24,14 @@ "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", + "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/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.test.ts b/scripts/ado-script/src/ado-proxy/ca.test.ts new file mode 100644 index 000000000..30a6df8cb --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/ca.test.ts @@ -0,0 +1,214 @@ +/** + * 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 { + closeSync, + mkdtempSync, + openSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +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"; + +const b64 = (value: string): string => Buffer.from(value, "utf8").toString("base64"); + +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 well-formed document", () => { + const materials = parseCaMaterials(material()); + expect(materials.caCertPem).toContain("BEGIN CERTIFICATE"); + 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( + 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 material on stdin/); + expect(() => parseCaMaterials(" \n ")).toThrow(CaError); + }); + + 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 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 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 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("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/, + ); + }); + + it("rejects a half-formed leaf rather than serving it", () => { + expect(() => + parseCaMaterials(material({ leaves: { "dev.azure.com": { cert: b64(CERT) } } })), + ).toThrow(/key must be a non-empty base64 string/); + expect(() => + parseCaMaterials(material({ leaves: { "dev.azure.com": { key: b64(KEY) } } })), + ).toThrow(/cert must be a non-empty base64 string/); + }); + + 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 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("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( + material({ token: b64('### HOST evil\n-----BEGIN PRIVATE KEY-----') }), + ); + expect([...materials.leaves.keys()]).toEqual(["dev.azure.com"]); + expect(materials.token).toContain("### HOST evil"); + }); +}); + +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", () => { + 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. + 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.json"); + writeFileSync(path, material()); + 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 new file mode 100644 index 000000000..1125c1b63 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/ca.ts @@ -0,0 +1,235 @@ +/** + * 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, the + * per-host leaves, and the bearer straight into `docker run -i`. Two + * consequences: + * + * - **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. + * + * 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 { chmodSync, 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; + readonly cert: string; +} + +/** Parsed interception material. */ +export interface CaMaterials { + /** PEM of the CA certificate. Safe to publish. */ + readonly caCertPem: string; + /** Leaf key/cert per host, keyed by lowercase hostname. */ + readonly leaves: ReadonlyMap; + /** + * The Azure DevOps bearer. + * + * 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(/=+$/, ""); +} + +/** + * Decode one base64 field. + * + * 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. + */ +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; +} + +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 material document. + * + * 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 material on stdin; the host generation step must pipe the certificates " + + "and bearer into this container", + ); + } + + 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"); + + 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`, + ); + } + + 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("material carried no host leaves"); + } + + return { caCertPem, leaves, token }; +} + +/** + * Read the material from a file descriptor, defaulting to stdin. + * + * 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 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 and the + * bearer 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 }); + // `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/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..bdaf543c4 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/catalog-drift.test.ts @@ -0,0 +1,143 @@ +/** + * 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("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", () => { + 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..db936543a --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/catalog.gen.json @@ -0,0 +1,632 @@ +{ + "schema_version": "ado-aw/ado-proxy-catalog/v1", + "runtime_available": true, + "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.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", + "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", + "getDefaultTeamImageUrl" + ], + "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..aadb0c0fd --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/config.test.ts @@ -0,0 +1,269 @@ +/** + * 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", + "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("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: "" }], + ])("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, + "--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}`, + "--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.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); + }); + + 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, + "--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"); + }); +}); 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..90182baf6 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/config.ts @@ -0,0 +1,397 @@ +/** + * 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"; +import { projectScopeDefaults } from "./scope.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; + /** 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; + /** + * 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; +} + +/** 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. + * + * 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; + /** + * 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. */ + 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", + "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. + * + * 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") + : [], + additional_scopes: parseAdditionalScopes(document), + }; +} + +/** 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"; + 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", + "AWF_POLICY_PROXY_UPSTREAM_PROXY", + ), + 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..24e9640b7 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/index.ts @@ -0,0 +1,134 @@ +/** + * `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. 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 { 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"; + +function report(message: string): void { + process.stderr.write(`[ado-proxy] ${message}\n`); +} + +/** 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 { + // 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; + report(`cannot establish the interception identity: ${error.message}`); + return 1; + } + + const deps = { + config, + 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); + + 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} (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(",")}`, + ); + + // 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`); + 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")); + 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..51a0ffc03 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/policy.test.ts @@ -0,0 +1,352 @@ +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"], +}; + +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, + 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("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. + 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 — 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 + // 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..e6d3be949 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/policy.ts @@ -0,0 +1,307 @@ +/** + * 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 { 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"; +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, + 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 || !scopes.hasOrganization(organization)) { + return deny( + "out-of-scope", + "request names an organization outside the policy", + 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; + // 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 project outside the policy for this organization", + operation.id, + ); + } + return undefined; + } + + case "current-repository-path": { + const project = params.project; + const repository = params.repository; + if (project === undefined) { + return deny("out-of-scope", "request names no project", operation.id); + } + // 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 repository outside the policy for this project", + 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, + 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`); + } + + 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, scopes); + 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..82d48f91c --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/proxy.e2e.test.ts @@ -0,0 +1,731 @@ +/** + * 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, 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"; +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 { 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"; +import { createDirectTlsServer, HEALTH_PATH, createProxyServer } from "./server.js"; +import { ScopeIndex } from "./scope.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 directTlsPort: number; + readonly proxyCaPem: string; + readonly upstreamCalls: UpstreamCall[]; + readonly tunnelTargets: string[]; + readonly materialDocument: 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); + }); + }); +} + +/** + * 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 document for feeding the + * engine. + */ +function mintForTest(directory: string, hosts: readonly string[]): { + materials: CaMaterials; + document: string; +} { + mkdirSync(directory, { recursive: true }); + const run = (args: readonly string[]): void => { + execFileSync("openssl", args as string[], { + cwd: directory, + 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", + "-subj", "/CN=ado-proxy test CA", "-keyout", "ca.key", "-out", "ca.pem", + "-addext", "basicConstraints=critical,CA:TRUE,pathlen:0", + ]); + + const leaves: Record = {}; + 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"]); + leaves[host] = { key: b64("leaf.key"), cert: b64("leaf.pem") }; + } + + 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`. */ +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(); + }); +} + +/** + * Connect straight to the direct-TLS listener, as a redirected client does. + * + * No CONNECT and no proxy configuration: this is what `--add-host` produces for + * the MCP, and what the `az` wrapper produces by being pointed at the engine. + */ +function directTlsRequest( + port: number, + host: string, + path: string, + options: { method?: string; headers?: Record; 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-")); + + const upstreamCa = mintForTest(join(workdir, "upstream-ca"), ["dev.azure.com"]).materials; + 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 proxyCaDocument = proxyMaterial.document; + + 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 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}`, + publicCaFile: join(workdir, "ca.pem"), + policy: POLICY, + }; + + const server = createProxyServer({ + config, + ca: proxyCa, + tokens: new TokenSource(CANARY), + scopes: ScopeIndex.from(POLICY), + log: new DecisionLog(join(workdir, "decisions")), + upstreamCa: upstreamCa.caCertPem, + }); + 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(CANARY), + scopes: ScopeIndex.from(POLICY), + 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, + materialDocument: proxyCaDocument, + }; + + // 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("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( + 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 () => { + 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("refuses to start without a bearer, rather than forwarding unauthenticated", () => { + // 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 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", () => { + // 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/response.test.ts b/scripts/ado-script/src/ado-proxy/response.test.ts new file mode 100644 index 000000000..5248e5822 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/response.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; + +import { CATALOG_SCHEMA_VERSION, OPERATIONS } from "./catalog.js"; +import type { ProxyPolicy } from "./config.js"; +import { filterResponse, isProtectedLocation, rewriteLocationUrl } 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; +} + +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, + ); +} + +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, SELF_ORIGIN); + 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"), + SELF_ORIGIN, + ); + expect(outcome.kind).toBe("deny"); + }); +}); + +describe("filterResponse — resource areas", () => { + 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", 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://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(); + }); +}); + +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..6042c764e --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/response.ts @@ -0,0 +1,270 @@ +/** + * 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 { ScopeIndex } from "./scope.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() + ); +} + +/** + * 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); +} + +/** + * 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 ( + typeof value === "string" && + typeof project === "string" && + scopes.allowsRepository(organization, project, value) + ); +} + +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, + /** + * 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, + /** 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); + + 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 && + (inScopeProject(project.name, scopes, organization) || + inScopeProject(project.id, scopes, organization)) + ); + }); + 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"); + // 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 }); + } + + 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) => inScopeProject(candidate, scopes, organization)) + ) { + 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); + // 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 ( + !inScopeRepository(repository.name, projectKey, scopes, organization) && + !inScopeRepository(repository.id, projectKey, scopes, organization) + ) { + return denyBody("resource belongs to a repository outside the policy"); + } + 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)}`); + } + } +} + +/** + * 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; + 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/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); + }); +}); 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 new file mode 100644 index 000000000..04aeacb8d --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/server.ts @@ -0,0 +1,605 @@ +/** + * 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 { 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"; +import { ScopeIndex } from "./scope.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; + /** + * 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. */ +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, + 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, + 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; + // 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}`, + deps.scopes, + requestOrganization ?? deps.config.policy.organization, + ); + + 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): { + /** 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 || ""); + 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 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; + } + callback(null, createSecureContext({ key: leaf.key, cert: leaf.cert })); + }, + }); + 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 { + 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), + }; +} + +/** 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.attach(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; +} + +/** + * 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; +} 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..7dcab775b --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/token.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { TokenError, TokenSource, bearerHeader } from "./token.js"; + +describe("TokenSource", () => { + it("holds the bearer supplied at construction", () => { + expect(new TokenSource("abc123").read()).toBe("abc123"); + }); + + 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("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("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"); + }); +}); + +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..f49918661 --- /dev/null +++ b/scripts/ado-script/src/ado-proxy/token.ts @@ -0,0 +1,60 @@ +/** + * Access to the Azure DevOps bearer. + * + * 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. + * + * 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. + */ + +export class TokenError extends Error {} + +/** + * 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 #value: string; + + constructor(value: string) { + const trimmed = value.trim(); + if (trimmed === "") { + throw new TokenError("the Azure DevOps bearer is empty"); + } + this.#value = trimmed; + } + + /** + * Return the bearer. + * + * 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 { + return this.#value; + } +} + +/** Format the bearer for the `Authorization` header. */ +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/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..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 @@ -55,10 +55,21 @@ 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 peers running + displayName: Verify trusted topology peers + - bash: echo stop + displayName: Stop ado-proxy - task: DownloadPipelineArtifact@2 inputs: targetPath: in @@ -86,7 +97,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 () => {}), @@ -238,7 +255,7 @@ 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", "multi-repo", @@ -246,7 +263,7 @@ 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", "tests/smoke/multi-repo.md", @@ -264,7 +281,7 @@ 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", @@ -428,7 +445,7 @@ describe("smoke-e2e index.main (per-case ref retention)", () => { "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/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/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/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, }, }); diff --git a/scripts/az-probe.mjs b/scripts/az-probe.mjs new file mode 100644 index 000000000..85c365bb8 --- /dev/null +++ b/scripts/az-probe.mjs @@ -0,0 +1,419 @@ +/** + * 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}"), + // 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(); + 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. 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")) { + const areas = upstreamResourceAreas(); + return json({ count: areas.length, value: areas }); + } + return json({ id: path.split("/").pop(), name: "git", locationUrl: `https://vsrm.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) => { + 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, host, 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}`); + // 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}`); +} + +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/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 }); 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/ado_proxy/catalog.rs b/src/ado_proxy/catalog.rs new file mode 100644 index 000000000..576823dbd --- /dev/null +++ b/src/ado_proxy/catalog.rs @@ -0,0 +1,795 @@ +//! 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* 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"; + +/// 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, +} + +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", + } + } + + /// 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)] +#[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.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, + 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", + // 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", + 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_is_available() { + let catalog = catalog(); + assert_eq!(catalog.schema_version, CATALOG_SCHEMA_VERSION); + assert!( + catalog.runtime_available, + "the compiler, topology, credential and scope wiring are complete" + ); + 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..b84c863f3 --- /dev/null +++ b/src/ado_proxy/mod.rs @@ -0,0 +1,38 @@ +//! 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; +pub mod policy; diff --git a/src/ado_proxy/policy.rs b/src/ado_proxy/policy.rs new file mode 100644 index 000000000..2321e0953 --- /dev/null +++ b/src/ado_proxy/policy.rs @@ -0,0 +1,518 @@ +//! 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, +}; +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}"; + +/// 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 +/// `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, + /// 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. + /// + /// 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(front_matter: &FrontMatter) -> Self { + let requested = ado_proxy_capabilities(front_matter); + let capabilities = requested + .iter() + .map(|capability| capability.as_str()) + .collect(); + + Self { + catalog_version: CATALOG_SCHEMA_VERSION, + organization: ORGANIZATION_PLACEHOLDER.to_string(), + project: PROJECT_PLACEHOLDER.to_string(), + // 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()), + 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, + // which is the single failure mode the proxy cannot tolerate. + protected_hosts: vec![ORGANIZATION_HOST, SPS_FALLBACK_HOST], + } + } + + /// 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 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 + .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() + } + + /// 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) + .expect("PolicyDocument is a plain serializable struct") + } +} + +#[cfg(test)] +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 + } + + 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 + } + + 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 + endpoint: github-templates + - 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")); + 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(&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" + ); + } + + #[test] + fn unrequested_capabilities_are_absent() { + let document = PolicyDocument::new(&with_capabilities("repos")); + for capability in Capability::ALL { + if capability.is_always_on() || *capability == Capability::Repos { + continue; + } + assert!( + !document.capabilities.contains(&capability.as_str()), + "{} 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() + ); + } + } + + #[test] + fn every_catalogued_protected_host_is_declared() { + let document = PolicyDocument::new(&plain()); + 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 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 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()); + 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 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 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/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..3d9d9386e 100644 --- a/src/audit/cli.rs +++ b/src/audit/cli.rs @@ -10,9 +10,10 @@ 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::find_artifact_dir; use crate::audit::findings; use crate::audit::model::{AuditData, ErrorInfo, FileInfo, OverviewData}; use crate::audit::pipeline_graph; @@ -467,6 +468,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, @@ -951,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") @@ -1018,6 +1023,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/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/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/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, 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/compile/ado_bundle.rs b/src/compile/ado_bundle.rs index b8d8b5e0d..7717b2877 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, } } @@ -195,7 +208,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 0baafb825..9abbb39e3 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -66,9 +66,14 @@ 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_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, 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; +use crate::ado_proxy::policy::PolicyDocument; use super::custom_tools::{CustomToolDefinition, collect_custom_tool_definitions}; use super::extensions::{CompileContext, CompilerExtension, Declarations, Extension, McpgConfig}; use super::ir::condition::{Condition, Expr}; @@ -168,6 +173,10 @@ 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_safe_outputs_keys(front_matter)?; front_matter @@ -362,7 +371,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( @@ -1125,6 +1135,20 @@ 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 = common::ado_proxy_enabled(front_matter); + if ado_proxy_enabled { + 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))); + } + // 15. MCP Gateway (MCPG), which launches SafeOutputs as a stdio child. steps.push(Step::Bash(start_mcpg_step( &cfg.mcpg_docker_env, @@ -1133,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())); @@ -1195,6 +1227,7 @@ fn build_agent_job( &cfg.engine_env, &cfg.byom_exclude_keys, front_matter.supply_chain(), + ado_proxy_enabled, )?)); // 18a. Revoke the GitHub App token (best-effort, always) once the Copilot @@ -1226,6 +1259,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)?)); @@ -3684,8 +3726,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\ @@ -3823,6 +3875,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, @@ -3831,6 +3884,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 @@ -3847,8 +3901,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!( @@ -3873,7 +3959,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 \ @@ -4012,6 +4098,80 @@ fn safe_outputs_summary_step(reviewed: &[String]) -> BashStep { .with_condition(Condition::Always) } +/// 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\ + # 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 --internal {ADO_PROXY_NETWORK_NAME}\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\ + # 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", 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\ @@ -4022,6 +4182,295 @@ 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`]. +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 + // 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\ + #\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\ + 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 \ + -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\ + # 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\" \\\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 \ + -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, 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\ + # 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\ + # 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 \ + -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\ + 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 only after the engine has parsed policy,\n\ + # published its public CA and reached its listening state.\n\ + PROXY_READY=false\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\" ] \\\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 \"##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, + 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, + 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. +fn stop_ado_proxy_step() -> BashStep { + let script = format!( + "# 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\ + echo \"ado-proxy stopped\"\n" + ); + 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\ + 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) +} + 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 @@ -4057,6 +4506,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" ); @@ -5302,6 +5755,405 @@ safe-outputs: assert!(msg.contains("missing `env:` key"), "got: {msg}"); } + + #[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 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}" + ); + 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_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(&proxy_fm()).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 + // 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_network_step().script; + assert!( + script.contains("--internal"), + "the MCP must not be able to route past the policy engine: {script}" + ); + } + + #[test] + 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!( + script.matches("ADO_PROXY_ORGANIZATION=$(").count(), + 1, + "exactly one derivation, shared with engine.rs" + ); + } + + // ── 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" + ); + } + + + /// 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] + 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(&proxy_fm()).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)) { + let container_private_fifo = line.contains("docker exec -i") + && line.contains("/tmp/ado-proxy-material"); + assert!( + !line.contains("/tmp/gh-aw") + && (!line.contains("> /tmp") || container_private_fifo), + "{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(&proxy_fm()); + + assert!( + 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. + 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_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!(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"); + } + + #[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; + 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(&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. + assert!(script.contains("--public-ca-file /var/lib/ado-proxy/ado-proxy-ca.pem")); + assert!(script.contains(&format!("-v {AZ_WRAPPER_DIR}:/var/lib/ado-proxy"))); + assert!( + 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!( + !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(&proxy_fm()).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(&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" + ); + // 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(&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))); + } + + #[test] + fn ado_proxy_embeds_a_policy_the_bundle_will_accept() { + 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\""), + "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(&proxy_fm()) + .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] @@ -5666,7 +6518,6 @@ safe-outputs: "ubuntu-22.04" ); } - // ─── build_agent_content: imported-body delivery ───────────────────────── #[test] diff --git a/src/compile/az_wrapper.rs b/src/compile/az_wrapper.rs new file mode 100644 index 000000000..f3e2db72e --- /dev/null +++ b/src/compile/az_wrapper.rs @@ -0,0 +1,309 @@ +//! 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_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. `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, + 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 +# 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 + +# 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. +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, + Capability::ALL, + ) + } + + #[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 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(); + 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 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 + // same call, or to conclude the pipeline is broken. + 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(); + // 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 95c0e728a..fd8adf8bb 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::{ @@ -45,6 +46,63 @@ 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)); +} + +#[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). @@ -545,6 +603,77 @@ 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 = ado_proxy_enabled(front_matter); + 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." + ) +} + +/// Validate explicit Stage 1 read-policy options before policy emission. +/// +/// 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<()> { + if ado_mcp_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() + .and_then(|permissions| permissions.read.as_ref()) + .and_then(crate::compile::types::ReadPermissionConfig::options) + else { + return Ok(()); + }; + + options.validate() +} + /// Validate the `variable-groups:` front-matter block (issue #1385). /// /// Enforces two rules before the pipeline is built: @@ -1605,14 +1734,222 @@ 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. +/// +/// 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. +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`. +/// +/// 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() -> 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. +/// +/// `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() + .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. +/// +/// 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. +/// +/// 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. +/// +/// 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. +/// +/// 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"; +/// 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"; +/// 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 @@ -3877,20 +4214,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" ); } @@ -5576,6 +5918,115 @@ 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_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] + 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", + ) + .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(); + 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("lists no projects"), + "must explain the widening omission: {error}" + ); + } + + /// 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() { + 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()); + + 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() { + 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] fn test_validate_front_matter_identity_rejects_macro_in_description() { let mut fm = minimal_front_matter(); @@ -6056,22 +6507,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""#), @@ -6561,16 +7019,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}" ); } @@ -6669,6 +7128,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", ) @@ -6676,12 +7138,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}" ); } @@ -6743,12 +7201,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] @@ -6903,6 +7364,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", ) @@ -6910,8 +7376,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/ado_script.rs b/src/compile/extensions/ado_script.rs index a9343ee85..a57d95735 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. diff --git a/src/compile/extensions/azure_cli.rs b/src/compile/extensions/azure_cli.rs index 593b5503b..c04570c03 100644 --- a/src/compile/extensions/azure_cli.rs +++ b/src/compile/extensions/azure_cli.rs @@ -1,15 +1,19 @@ 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, +}; 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 @@ -46,17 +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.** `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. +/// 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 { @@ -76,7 +73,25 @@ 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 { + 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())]; + // 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![ // OAuth + sign-in @@ -90,15 +105,52 @@ 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: vec![AZ_WRAPPER_DIR.to_string()], ..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(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!( + "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 { @@ -113,24 +165,130 @@ fn detection_bash_step() -> BashStep { BashStep::new("Detect Azure CLI on host (for AWF mount)", script) } -/// 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\ +/// 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. +/// 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(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\ ## 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 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); 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\ -- **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\ +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 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 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" + ); + + 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()), @@ -144,9 +302,162 @@ 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") } + /// `permissions.read` pulls in the policy engine and therefore the wrapper; + /// the MCP tool is independent. + fn fm_proxied() -> FrontMatter { + fm() + } + + 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() + } + + 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 + // 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()); + } + + #[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 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'")); + } + + #[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 } @@ -200,15 +511,15 @@ mod tests { 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 @@ -348,7 +659,7 @@ mod tests { ); } - // ── Conditional prompt-append step (step index 1) ────────────────────── + // ── Conditional Azure CLI prompt step ────────────────────────────────── #[test] fn test_azure_cli_prompt_append_step_is_conditional() { @@ -361,7 +672,11 @@ mod tests { 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( @@ -380,7 +695,11 @@ mod tests { 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 @@ -394,18 +713,23 @@ 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(); 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", - "AZURE_DEVOPS_EXT_PAT", + "pre-configured for Azure DevOps reads", + "policy proxy", + "safe output", "missing-tool", ] { assert!( @@ -414,27 +738,31 @@ 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); 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 \ - 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 ); } @@ -450,7 +778,11 @@ mod tests { 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"); } @@ -477,15 +809,14 @@ mod tests { } #[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 32aa32d93..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); @@ -301,12 +315,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 +342,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/ir/env.rs b/src/compile/ir/env.rs index ec2adbd44..da971c0ed 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 0183a94da..769cf729f 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; @@ -37,14 +38,22 @@ 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; 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/compile/types.rs b/src/compile/types.rs index 41f9199ac..6ec4ce245 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -1426,11 +1426,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, /// Abstract permissions required by this workflow and its imported @@ -2696,9 +2696,9 @@ impl PermissionsRequired { /// 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 @@ -2707,20 +2707,20 @@ impl PermissionsRequired { /// 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. @@ -2728,6 +2728,171 @@ 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 configures the credential-isolated proxy's capability and scope tree. +#[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, +} + +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 { + Core, + #[serde(rename = "repos")] + Repositories, + Pipelines, + 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. + /// + /// 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, + 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, +} + +/// 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, + /// 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 + /// silent widening: it grants the project-scoped reads (pipelines, builds, + /// work items) without granting any repository-scoped read. + #[serde(default)] + pub repositories: Vec, +} + /// Debug-only configuration block. /// /// Lives under the `ado-aw-debug:` top-level front-matter key. Holds knobs @@ -2975,7 +3140,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)] @@ -3045,7 +3213,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), @@ -4230,7 +4398,9 @@ imports: write: true, }; let concrete = PermissionsConfig { - read: Some("read-connection".to_string()), + read: Some(ReadPermissionConfig::ServiceConnection( + crate::secure::ServiceConnection::parse("read-connection").unwrap(), + )), write: None, }; assert!(required.missing_from(Some(&concrete)).is_empty()); @@ -4248,7 +4418,9 @@ imports: assert!( required .validate_against(Some(&PermissionsConfig { - read: Some("read".to_string()), + read: Some(ReadPermissionConfig::ServiceConnection( + crate::secure::ServiceConnection::parse("read").unwrap(), + )), write: Some("write".to_string()), })) .is_ok() @@ -5298,7 +5470,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")); } @@ -5306,10 +5483,160 @@ 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 + project-id: 11111111-1111-1111-1111-111111111111 + 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] + .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" + ); + } + + #[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 allow:\n - organization: org\n projects:\n - project: Project\n project-id: not-a-guid", + "read:\n service-connection: sc\n unknown: value", + ] { + assert!( + serde_yaml::from_str::(yaml).is_err(), + "invalid read policy must fail deserialization:\n{yaml}" + ); + } + } + + /// 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"; @@ -5343,7 +5670,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/engine.rs b/src/engine.rs index a00eb1382..fc68a6793 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1100,23 +1100,24 @@ 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() + .lines() + .map(|line| format!(" {line}\n")) + .collect::(); + 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()) } }; diff --git a/src/inspect/catalog.rs b/src/inspect/catalog.rs index 2914d3cd8..d548e417c 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)] @@ -58,6 +65,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 +78,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 +94,7 @@ pub enum CatalogKind { Engines, Models, Versions, + AdoProxy, } impl CatalogKind { @@ -96,6 +106,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 +122,7 @@ pub fn catalog() -> Catalog { engines: engines(), models: models(), versions: Some(versions()), + ado_proxy: Some(crate::ado_proxy::catalog::catalog()), } } @@ -141,6 +153,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 +209,29 @@ 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 { + 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() } @@ -202,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(), } } @@ -412,4 +452,17 @@ mod tests { assert_eq!(value["versions"]["awf"], AWF_VERSION); assert_eq!(value["versions"]["mcpg"], MCPG_VERSION); } + + #[test] + 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.operations.is_empty()); + assert!(catalog.safe_outputs.is_empty()); + } } 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/main.rs b/src/main.rs index 604c1f592..db4f5ff5e 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; @@ -583,6 +584,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. @@ -647,6 +664,8 @@ impl Commands { Commands::Trace { .. } => "trace", Commands::ExportGateSchema { .. } => "export-gate-schema", Commands::ExportFactCatalog { .. } => "export-fact-catalog", + Commands::ExportAdoProxyCatalogSchema { .. } => "export-ado-proxy-catalog-schema", + Commands::ExportAdoProxyCatalog { .. } => "export-ado-proxy-catalog", Commands::Inspect { .. } => "inspect", Commands::Graph { .. } => "graph", Commands::Whatif { .. } => "whatif", @@ -1606,6 +1625,14 @@ async fn main() -> Result<()> { let catalog = compile::filter_ir::generate_fact_catalog(); write_or_print(&catalog, output)?; } + Commands::ExportAdoProxyCatalogSchema { output } => { + let schema = ado_proxy::catalog::generate_catalog_schema(); + write_or_print(&schema, output)?; + } + Commands::ExportAdoProxyCatalog { output } => { + let catalog = ado_proxy::catalog::generate_catalog_json(); + write_or_print(&catalog, output)?; + } Commands::Inspect { source, json } => { inspect::dispatch_inspect(inspect::InspectOptions { source: &source, 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/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(); diff --git a/src/secure.rs b/src/secure.rs index b89b6682c..074c7de44 100644 --- a/src/secure.rs +++ b/src/secure.rs @@ -33,7 +33,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 @@ -367,6 +369,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| { @@ -408,6 +431,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, @@ -517,6 +598,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/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/src/validate.rs b/src/validate.rs index 7960639b7..13c66a7c3 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -726,6 +726,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. diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 4aa535fe9..fa3c72afd 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1950,6 +1950,113 @@ 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"); + 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() { @@ -1983,6 +2090,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("${{", ""); @@ -2021,22 +2145,69 @@ 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("--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" + ); 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("\"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); @@ -5833,162 +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, what it's good for, and the auth - // model. 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", - "AZURE_DEVOPS_EXT_PAT", - "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 @@ -9763,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/azure-devops-mcp-agent.md b/tests/fixtures/azure-devops-mcp-agent.md index 7c2b2f249..31424a3ce 100644 --- a/tests/fixtures/azure-devops-mcp-agent.md +++ b/tests/fixtures/azure-devops-mcp-agent.md @@ -1,6 +1,13 @@ --- 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 + endpoint: github-templates + checkout: false tools: azure-devops: org: myorg @@ -11,7 +18,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] + 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: 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. 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. diff --git a/tests/safe-outputs/README.md b/tests/safe-outputs/README.md index ac5ed5125..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 AWF az CLI extension is mounted, `az devops` authenticates via `AZURE_DEVOPS_EXT_PAT`, and the sandbox can reach the ADO control plane. | | `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 7e1d5f74f..000000000 --- a/tests/safe-outputs/azure-cli.md +++ /dev/null @@ -1,61 +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 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: - - ``` - az devops project list \ - --organization "$(System.CollectionUri)" \ - --query 'value[0:3].name' \ - -o tsv - ``` - - 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 the captured project-list 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 9c9ff1daa..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, 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 @@ -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/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/ado-proxy.md b/tests/smoke/ado-proxy.md new file mode 100644 index 000000000..250ebdde8 --- /dev/null +++ b/tests/smoke/ado-proxy.md @@ -0,0 +1,117 @@ +--- +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. 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. + +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)` + +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..79cffd7fa 100644 --- a/tests/smoke/cases.json +++ b/tests/smoke/cases.json @@ -20,16 +20,33 @@ "source": "tests/safe-outputs/canary.md" }, { - "id": "azure-cli", + "id": "ado-proxy", "lane": "agentic", "kind": "compiled", - "modes": ["candidate", "released"], - "source": "tests/safe-outputs/azure-cli.md", + "modes": ["candidate"], + "source": "tests/smoke/ado-proxy.md", "assertions": { "agentCommand": { - "required": ["shell(az", "shell(head"], - "forbidden": ["--allow-all-tools", "--allow-all-paths"] - } + "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: Verify trusted topology peers", + "displayName: Stop ado-proxy" + ], + "forbidden": [ + "-e ADO_MCP_AUTH_TOKEN=\"$SC_READ_TOKEN\"", + "--network host" + ] + }, + "requiredBuildTags": ["ado-aw-proxy-{buildId}"] } }, {