Skip to content

feat(mcp): refresh Azure identity for long-running tasks - #2097

Open
jamesadevine wants to merge 20 commits into
mainfrom
feat/cross-org-repository-writes
Open

feat(mcp): refresh Azure identity for long-running tasks#2097
jamesadevine wants to merge 20 commits into
mainfrom
feat/cross-org-repository-writes

Conversation

@jamesadevine

Copy link
Copy Markdown
Collaborator

Summary

  • add mcp-servers.<name>.azure-auth for ARM workload-identity service connections
  • start a trusted AzureCLI@3-backed sidecar that refreshes assertions through System.OidcRequestUri
  • atomically rotate a token in a private directory mounted read-only into the stdio MCP container
  • keep System.AccessToken and refresh credentials out of the agent, MCP environment, Docker arguments, logs, and artifacts
  • add fail-closed validation, lifecycle cleanup, documentation, and a manual delayed-expiry Azure-native E2E pipeline

Closes #1964.

Test plan

  • cargo test
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test --test generated_shell_guard
  • cargo test --test bash_lint_tests
  • npm test in scripts/ado-script
  • npm run typecheck in scripts/ado-script
  • npm run build:azure-wif-refresh in scripts/ado-script
  • parsed tests/azure-wif-refresh-e2e/azure-pipelines.yml

The credentialed Azure-native E2E definition is included, but its live run requires an authorized ARM workload-identity service connection.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Refresh cached Azure DevOps clients when short-lived tokens rotate and add direct coverage for default auth, PR repository selectors, and branch/tag E2E scenario behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Verify a failed required cross-organization target is retained while later repository preparation continues.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Use a closed token-variable enum, restrict repository identity folding to ASCII, update proxy credential docs, and complete failure-path coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Pin the complete repos entry emitted alongside expanded write permissions in executor E2E sources.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Assert repository target names with more than project/repository segments fail closed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3c8e5f36-958b-48ea-99cd-d7c14b5eb8da
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d6d08ba7-33cd-4c10-8c9e-69f19c8e3132
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
1 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@jamesadevine

Copy link
Copy Markdown
Collaborator Author

/review

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Rust Code Quality Reviewer completed the Rust code quality review.

🦀 Rust code quality review by Rust Code Quality Reviewer

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeScript review — scripts/ado-script/src/azure-wif-refresh/

Reviewed the new sidecar script (773 lines) plus its test suite. ts-critic ran in the background but returned no findings; my own pass agrees there is nothing merge-blocking here.

Highlights that make this solid:

  • systemAccessToken/initialIdToken never reach a log line or thrown Error — all report(...) calls use static, sanitized strings.
  • Network calls go through requestWithTimeout with an AbortSignal race plus a hard timeout, and retries back off correctly against the assertion's actual expiry.
  • writeAtomic uses a same-directory temp file + rename, with cleanup of the handle and temp file on every failure path.
  • parseMaterial allow-lists fields and validates every value's shape (including a GUID regex for serviceConnectionId) before anything touches the state machine.

One minor, non-blocking nit worth a look before merge: in onData (readOneJsonDocument), Buffer.byteLength(buffer) is recomputed against the entire accumulated buffer on every chunk to enforce MAX_MATERIAL_BYTES, which is O(n2) for many small chunks. Given input is bounded to 1MB from a trusted host, this is a performance nit rather than a defect — track a running byte count instead if this ever gets exercised with high chunk counts.

No inline comments needed.

🟦 TypeScript code quality review by TypeScript Code Quality Reviewer · auto · 40.9 AIC · ⌖ 10.5 AIC · ⊞ 11.6K
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review — no regressions found

Reviewed the new Azure workload-identity refresh sidecar (azure-wif-refresh.js), its Rust wiring (agentic_pipeline.rs, mcpg.rs, common.rs, ado_bundle.rs, azure_cli.rs, types.rs, secure.rs), and doc updates.

This is a well-defended addition to the credential-isolation model, not a weakening of it:

  • System.AccessToken and the initial idToken reach the sidecar only via a one-shot, mode-600 FIFO (mkfifo -m 600), never as a container env var, CLI argument, or file — confirmed in both the generated bash (START_AZURE_WIF_REFRESH) and the TypeScript parseMaterial/writeAtomic paths.
  • The refresher container is hardened (--cap-drop ALL, --security-opt no-new-privileges, --read-only, non-root --user, --pids-limit 64) and only the token-only subdirectory is mounted read-only into the target MCP container — the sidecar's own status/ready/material channels stay outside that mount.
  • New ContainerAbsolutePath validator (secure.rs) rejects traversal, : mount separators, shell metacharacters, and even ##vso[ injection in mount-path — good defense-in-depth for a value ultimately used in a Docker mount flag.
  • common.rs validation blocks user args/env/mounts from colliding with or overriding the compiler-owned AZURE_CLIENT_ID/AZURE_TENANT_ID/AZURE_FEDERATED_TOKEN_FILE env vars or the auth mount path, and azure-auth is correctly restricted to containerized stdio servers only (rejected for safeoutputs, extension-owned servers, and HTTP servers).
  • mcpg.rs's new bind_internal_pipeline_variable/required_names machinery keeps the compiler-internal client/tenant-id variables out of user-writable MCPG_ENV_NAMES while still enforcing they're non-empty and not an unresolved ADO macro ($() before MCPG starts — a good fail-closed check against silent misconfiguration.
  • The sidecar's report() logging only emits ErrorCategory strings, never token/material content, and errorCategory() derives categories from HTTP status/error codes without echoing response bodies.
  • Cleanup (STOP_AZURE_WIF_REFRESH) runs unconditionally (Condition::Always) and removes the private auth directory even on partial-startup failure.

No injection, token-scope, or path-traversal issues found in this diff. Nice attention to the /tmp chroot-exposure trap called out in the docs update — the credential directory correctly stays under a private subpath of Agent.TempDirectory, distinct from the shared runner /tmp that AWF exposes to the agent.

🔒 Security review by PR Security Reviewer · auto · 67.1 AIC · ⌖ 2.03 AIC · ⊞ 11.7K
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall the test coverage for this PR is strong — the mcpg.rs rejection paths (HTTP servers, env/mount collisions, unsafe mount paths) and the runRefresher state-machine tests are thorough and behavior-focused rather than implementation-mirroring.

Three gaps worth closing before merge:

  1. readOneJsonDocument (chunked stdin JSON parser with brace-depth/string-escape tracking) has zero direct test coverage.
  2. errorCategory classifies 7 categories but only throttled/invalid-response/generic-server paths are exercised indirectly through runRefreshertimeout, client, network, and unknown are untested.
  3. The new ContainerAbsolutePath validator's security-critical checks (##vso[ injection guard, control characters \0/\n/\r) aren't asserted in container_absolute_path_rules, only path-traversal and colon cases are.

None of these are blocking — the core refresh/rotation logic is well tested — but they're the highest-value additions if there's time before merge.

🧪 Test quality analysis by Test Quality Sentinel · auto · 67.8 AIC · ⌖ 2.47 AIC · ⊞ 11.3K
Comment /review to run again

@github-actions github-actions Bot mentioned this pull request Sep 3, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rust review — no merge-blocking defects found

Reviewed the Rust portions of this PR (src/compile/ado_bundle.rs, agentic_pipeline.rs, common.rs, extensions/ado_script.rs, extensions/mod.rs, ir/tasks/azure_cli.rs, mcpg.rs, types.rs, secure.rs, tests/compiler_tests.rs). Note: the rust-critic sub-agent I launched did not receive the diff content due to a shell-substitution issue on my side, so this review reflects only my own manual pass — flagging for transparency rather than treating it as a clean bill from two independent reviewers.

Positives:

  • McpgLaunchEnvironment::bindings is a BTreeMap<McpgEnvName, _>, so names()/required_names() iteration order is deterministic — important since it feeds generated YAML env lists.
  • The ContainerAbsolutePath validator correctly rejects traversal, :/$/backtick, and empty/./.. segments before the path is ever used to build a Docker mount destination.
  • Reserved-env and mount-collision checks in validate_stdio_mcp correctly bail before azure-auth credentials could be shadowed or a container mount could collide with the token mount.
  • SYSTEM_ACCESSTOKEN is injected via EnvValue::secret("System.AccessToken") rather than a literal, keeping it out of the rendered YAML/log surface, consistent with the project's credential-isolation model.

One non-blocking hardening note:
The Docker-flag detector in validate_stdio_mcp (common.rs ~L3269-3277) matches only unbundled short/long flags (-e, --env, -v, --volume, --mount, --volumes-from, and their =value forms) plus the -eX/-vX bundled-value case. It does not reject a bundled multi-short-flag form such as -itve where -e/-v is not the first character. I could not confirm whether the downstream runtime.extra_args consumer parses args in a way that would treat this as equivalent to -e/-v (most likely it passes them straight through to docker run's own arg vector, which would not be vulnerable), so this is flagged only for defense-in-depth review since it's a security-relevant guard, not because I have a working bypass.

Nothing else in the diff rises to merge-blocking: error handling uses anyhow with context consistently, no new unwrap/expect on user-reachable paths, and the new typed AzureCliV3/ContainerAbsolutePath/McpgLaunchEnvironment additions all follow existing patterns in this codebase.

🦀 Rust code quality review by Rust Code Quality Reviewer · auto · 98.1 AIC · ⌖ 12.7 AIC · ⊞ 11.6K
Comment /review to run again

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[agent-issue]: Gateway-managed, auto-refreshed Azure federated token file for user-defined MCP servers

1 participant