Repository navigation
feat(compile)!: credential-isolated package feed access via permissions.packages - #2315
jamesadevine wants to merge 7 commits into
Conversation
…ns.packages Agents can restore npm, PyPI, NuGet, and Cargo packages from Azure Artifacts feeds without holding a feed credential. The trusted ado-proxy container holds the build identity or a WIF-minted token and attaches it only to GET/HEAD requests for granted feeds, views, and protocols. - permissions.packages front matter (feeds, protocols, view, upstream, identity-role, optional service connection) and permissions-required.packages - ado-proxy catalog v2 package family with a separate credential slot, host-side feed GUID resolution and access preflight, blob-only redirect relay - per-process package-manager wrappers (npm, npx, pip, pip3, uv, dotnet, cargo) - runtimes.<x>.feed source selection and runtimes.<x>.public-registry: block - audit rollups by family/protocol and an upstream-unauthorized finding - codemod 0009 migrates Azure Artifacts runtimes.<x>.feed-url BREAKING CHANGE: Azure Artifacts runtimes.<x>.feed-url is replaced by permissions.packages plus runtimes.<x>.feed. Codemod 0009 migrates existing sources automatically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b0ab0d4-23f4-4a89-90ea-3c40758ea550
|
/review |
|
Azure Pipelines: 2 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
✅ TypeScript Code Quality Reviewer completed the TypeScript code quality review.
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Security review: no regressions found
This diff adds the package-feed credential-isolation proxy (permissions.packages). I reviewed the full diff against the threat model (token custody, injection into generated YAML/shell, path traversal, redirect SSRF, network allowlisting, safe-output integrity) and found no weakened security posture.
Notable positives that satisfied my checks rather than raised concerns:
- Credential isolation: the REST bearer and the new Azure Artifacts package credential are kept in separate material-document fields (
token/package_token), only ever read from env/stdin into the trustedado-proxyprocess, never written to disk, argv, or the Agent/Detection environment (ca.ts,agentic_pipeline.rs). - URL-shape authorization (
package.ts) rejects path traversal, encoded/double-encoded separators, backslashes, control characters, and mismatched scope/view/protocol grants, each pinned by an explicit deny-reason test — including a dedicated test asserting denial details never echo request content. - Redirect handling (
authorizePackageRedirect) never follows a redirect while holding the credential; it only relays to an allowlisted, suffix-anchored blob-storage host (.blob.core.windows.net,.vsblob.visualstudio.com) or a same-host route that is itself re-authorized, with explicit tests against look-alike hosts, userinfo, non-default ports, and non-https schemes. - Fail-closed wiring:
isInterceptedHostextends the HTTPS-only/port-443-only enforcement to package hosts, so a package host cannot be reached in cleartext or on a non-standard port to bypass policy;credentialProblemrefuses to start if a configured family lacks its credential or a package host lacks its TLS leaf. - New feed/view/project identifiers are validated newtypes (
secure.rs::AdoFeedName) rather than raw strings, consistent with the repo convention, so they cannot carry path/shell metacharacters into generated policy or the agent-facing prompt text. - Header allow-lists (
headers.ts) explicitly excludeauthorization,x-nuget-apikey,npm-otp, and other credential-bearing headers from both directions.
No merge-blocking findings. Nothing in pr-review-comments.json to avoid duplicating (list was empty).
🔒 Security review by PR Security Reviewer · auto · 89.1 AIC · ⌖ 2.64 AIC · ⊞ 11.4K
Comment /review to run again
There was a problem hiding this comment.
Exceptionally thorough test suite for a security-sensitive feature (credential isolation, URL-shape authorization, redirect validation, header sanitization) — the adversarial-encoding and canary-leak tests in package.test.ts and proxy.e2e.test.ts are a model for this kind of work.
Two gaps worth a look, both advisory:
validate_feed_exclusivity(new insrc/runtimes/mod.rs, wired into all three runtime extensions) has no direct unit test for its own error paths — only the pre-migration codemod shape is tested, not the compiler-level rejection ofruntimes.<x>.feedcombined withfeed-url/config.- The "cuts off a streamed body" e2e test swallows the promise's outcome with
.catch(() => undefined)and only asserts when it happens to resolve, with a bound loose enough to pass even if truncation silently regressed.
Nothing here blocks merge; the rest of the diff's test coverage (codemod round-tripping, policy cross-validation, per-protocol route tables, header allow/deny lists, audit-log scrubbing) is comprehensive and behavior-focused rather than implementation-mirroring.
🧪 Test quality analysis by Test Quality Sentinel · auto · 191 AIC · ⌖ 1.77 AIC · ⊞ 11K
Comment /review to run again
There was a problem hiding this comment.
Rust Code Quality Review 🦀
Verdict: no merge-blocking issues found. This is a large (26-file) but carefully engineered addition of permissions.packages credential-isolated Azure Artifacts access. cargo check passes cleanly on the branch.
💡 Review notes
- Error handling: consistent
anyhow::bail!/.context()usage; nounwrap()/expect()outside#[cfg(test)]modules. - Correctness: percent-decoding in the codemod URL parser is bounds-checked (
get(..)returnsOption, not panicking slice indexing); feed-handle collision resolution (upsert_grant) is deterministic; Cargo's/Cargo/index/URL casing is intentionally matched case-insensitively by the TS-sidematchProtocol, not a bug. - Determinism: protocol lists are sorted/deduped before serialization (
policy.rs), and audit stats useBTreeMap— consistent with the project's "generated YAML must be deterministic" rule. - Security-adjacent properties (credential custody, fail-closed wrapper routing) read as sound from the Rust side; the TS enforcement (
package.ts,resolve-feeds.ts) is out of this reviewer's remit and is covered by the TypeScript and compiler-contract reviewers. - Test coverage is thorough: codemod migration/idempotency, cross-field validation (
upstream: deny+ Collaborator role rejection), runtimefeedcross-referencing, and full pipeline-wiring assertions for both build-identity and WIF credential modes.
Process note: the rust-critic background sub-agent failed to start (400 model "gpt-5.4-mini" is not accessible), an infrastructure issue unrelated to this diff. This review reflects only my own direct pass over the Rust-only diff slice.
🦀 Rust code quality review by Rust Code Quality Reviewer · auto · 190.7 AIC · ⌖ 1.94 AIC · ⊞ 11.3K
Comment /review to run again
There was a problem hiding this comment.
TypeScript Code Quality Review — scripts/ado-script/src/ado-proxy/
Note: the ts-critic sub-agent failed to start (model-access error: 400 model "gpt-5.4-mini" is not accessible via the /chat/completions endpoint), so this review reflects only my own direct pass, not a cross-check.
Scope reviewed
All new/changed TypeScript under scripts/ado-script/src/ado-proxy/ for the package-feed proxy feature: package.ts/.test.ts, config.ts/.test.ts, headers.ts/.test.ts, server.ts (handlePackage, openUpstream, streamBounded), resolve-feeds.ts/.test.ts, ca.ts, catalog.ts, index.ts.
Findings
No merge-blocking defects found.
- Async/error handling:
openUpstream/handlePackage/streamBoundedcorrectly attacherrorhandlers on every socket/stream before awaiting, back-pressure is honoured instreamBounded, and the single sharedopenUpstreamhelper avoids duplicated upstream-dialing logic between the REST and package paths. Thefinally { secured?.destroy() }cleanup is present on both handlers. - Credential isolation:
token/packageTokensare now optional and read viareadCredential(), each family's upstream only ever receives its own credential, andca.ts'scredentialProblem()fails closed at startup if a configured family lacks its credential or TLS leaf. Good design. - Type safety: the few
as Segment/as stringcasts inpackage.ts::matchFeedRouteare all preceded by explicit length/undefined guards, so they're safe despite being non-null-assertion-adjacent. Thecaught as Errorpattern inresolve-feeds.tsis pre-existing repo-wide idiom, not newly introduced risk. - Secret handling:
resolve-feeds.tsredacts the token from every log line viaredact()beforeescapeLoggingCommand(), andca.tsfails closed on an empty-but-present credential field. I did spot what looked like a bareAuthorizationheader / bearer-string leak inpackage.ts::packageAuthorizationHeaderandresolve-feeds.ts'sget()headers when viewing the files through tooling, but confirmed againstgit show HEAD:...that this is a sandbox/tool redaction artifact (the real source reads`Bearer ${token}`), not an actual code defect — flagging here only for transparency since it looked alarming at first glance. - Tests: every new branch (redirect allow/deny, 401/203 handling, response-too-large before and during streaming, packages-only policy denying REST, missing-credential paths, cleartext/CONNECT refusal for package hosts) has a matching
proxy.e2e.test.ts/resolve-feeds.test.ts/package.test.tscase.
No inline comments posted — nothing met the bar for an actionable, line-specific defect.
🟦 TypeScript code quality review by TypeScript Code Quality Reviewer · auto · 172.1 AIC · ⌖ 6.61 AIC · ⊞ 11.3K
Comment /review to run again
…dPackages Live probes against Azure Artifacts showed two gaps: - npm, PyPI, and NuGet downloads redirect (303) to <shard>vsblobprodcus<n>.vsblob.vsassets.io, which the package redirect allowlist did not include, so every real download would have been refused with redirect-denied. Add the .vsblob.vsassets.io suffix (already covered by the core AWF *.vsassets.io allowlist entry). - The feed GET used by resolve-feeds answers 200 to an identity without the ReadPackages permission, so the preflight passed while every restore would fail. Preflight now lists one package, which requires ReadPackages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b0ab0d4-23f4-4a89-90ea-3c40758ea550
A comma-separated smokeCases runtime parameter narrows a candidate run to the named cases. Blank (the default, and every scheduled or comment-triggered run) still runs every case. Unknown ids, or ids outside the mode, fail the run rather than silently running less. The value reaches the harness only as the SMOKE_CASES environment variable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b0ab0d4-23f4-4a89-90ea-3c40758ea550
Register package-feeds (build identity) and package-feeds-wif (agent-playground-read) against the AgentPlaygroundTestFeed feed. Each restores through npm, pip, and dotnet via ado-proxy and proves no package credential reaches the sandbox. The build-identity case also proves ungranted feeds are refused and public-registry: block removes the public registry. The compiler test now checks every pipelineText assertion cases.json declares for these cases, so a compiler change that breaks one fails cargo test rather than the nightly smoke. REGISTERED.md records the feed, upstreams, and grants the cases depend on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b0ab0d4-23f4-4a89-90ea-3c40758ea550
…ctly Review follow-ups on #2315: - validate_feed_exclusivity had no direct test. Add unit tests for both conflicting combinations plus a per-runtime check through parse_markdown and each runtime extension. - The "cuts off a streamed body" e2e test swallowed rejections and only asserted when the request resolved. It now accepts exactly two outcomes, a partial body bounded by the limit or a connection reset, and requires a new response-too-large decision record either way. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b0ab0d4-23f4-4a89-90ea-3c40758ea550
This comment has been minimized.
This comment has been minimized.
…e prompts Live run 646826 showed Copilot CLI refusing every npm, pip, and dotnet invocation when tools.edit is false: without --allow-all-paths the CLI denies commands that write caches and temporary files outside the workspace, and the agent cannot request approval non-interactively. - permissions.packages now warns at compile time when tools.edit is false. - The package-feed smokes keep edit enabled (the default) and tell the agent not to chain extra commands onto the ones under test. - docs/package-feeds.md records the limitation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b0ab0d4-23f4-4a89-90ea-3c40758ea550
This comment has been minimized.
This comment has been minimized.
Live run 646837 still saw Copilot CLI refuse every wrapped npm command with --allow-tool "shell(npm)" and --allow-all-paths present, while unwrapped tools ran. The wrappers were symlinks to .ado-aw-package-wrapper; the working az wrapper is a regular file. Install one regular-file copy per tool name so the executable Copilot CLI resolves carries the allowed name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b0ab0d4-23f4-4a89-90ea-3c40758ea550
Prompt evaluationNote This is an advisory static review. Only Prompt Contracts is merge-blocking. Only
Potential regressionsNone. The added reference does not alter any instruction exercised by the three Pre-existing observation (not introduced by this PR, unaffected by the diff): Per-case scores
Evidence notes:
|
Summary
Agents can now restore packages from internal Azure Artifacts feeds with
npm,pip/uv,dotnet, andcargowithout ever holding a feed credential.The trusted
ado-proxycontainer, already used forpermissions.read, gainsa second package family. It holds the feed credential and attaches it only
to
GET/HEADrequests for the feeds, views, and protocols the workflowgrants. This is the follow-up to #2310, which removed the
*Authenticatetasks because they leaked the token into AWF. It addresses theinternal-feed half of #1823 and #253.
How it works
$(System.AccessToken)is mapped as a secret only intothe proxy start step.
AzureCLI@3mintsSC_PACKAGES_TOKEN, which is secret and in--exclude-env.starts. It is never placed in the Agent or Detection environment, argv,
workspace, or runner
/tmp.token(REST) andpackage_token(packages).
pkgs.dev.azure.com, and the packagecredential never reaches
dev.azure.com.GET/HEAD, a catalogued protocol route(
npm/registry/,pypi/simple|download/,nuget/v3/,cargo/), and agranted org/project/feed/view tuple. Feed GUID aliases are resolved on the
host before AWF starts.
ado-proxy.js resolve-feedsalso probes access. A failurestops the job with the feed, identity, and role to grant.
Locationonlyfor
*.vsblob.visualstudio.comand*.blob.core.windows.net, and theclient fetches the signed blob URL through Squid with no credential.
npm,npx,pip,pip3,uv,dotnet,cargo)set
HTTPS_PROXYand CA trust for that process only. CA trust is neverinstalled container-wide.
pkgs.dev.azure.comURLs, including ones in repository config,work unchanged.
python -m pip) fails closed with nocredential.
runtimes.<x>.feedsetsPIP_INDEX_URL/UV_DEFAULT_INDEX,NPM_CONFIG_REGISTRY, or an ensure-nuget.configstep, allcredential-free.
public-registry: blockremoves that ecosystem's public registry hostsfrom the AWF allowlist.
upstream: deny(the default) therefore requires either avieworidentity-role: reader, otherwise compilation fails.upstream: allowis an explicit opt-in.familyandprotocolfields;the log stays v1-compatible. The changes add rollups and an
upstream-unauthorizedfinding.Breaking change and codemod
An Azure Artifacts
runtimes.<x>.feed-urlis replaced bypermissions.packagesplusruntimes.<x>.feed. Codemod 0009 migrates itautomatically:
@viewis kept;upstream: allowis added when no view is present, preserving the buildidentity's previous behavior.
Non-Artifacts
feed-urlvalues are unchanged.Deviations from the plan (for reviewers)
{org}.pkgs.visualstudio.compermissions.read.config:and checked-in config linting.cargo/config.toml(#1823).tests/smoke/package-feeds.md) and compile-tested, but not registered incases.json. It needs a provisioned feed. The runbook and exact entry are intests/smoke/REGISTERED.md.Needs live validation before relying on it
Bearer, PyPI/NuGetBasic(
ado-aw:<token>). This is unverified for Entra tokens over Basic.uv/dotnethonoringSSL_CERT_FILEon the hosted image.The
package-feedssmoke covers the first two once the feed exists.Docs:
docs/package-feeds.md;ado-proxy-design.md,runtimes.md,network.md,codemods.md,imports.md,front-matter.md,safe-output-permissions.md, AGENTS.md,and README.
Test plan
All run locally on Windows with
shellcheckavailable:cargo build: passedcargo test --no-fail-fast: passed (3743 passed, 0 failed, 2 ignored)cargo clippy --all-targets: passed (no warnings)ENFORCE_BASH_LINT=1 cargo test --bin ado-aw compile::shell: passed (59)ENFORCE_BASH_LINT=1 cargo test --test bash_lint_tests: passed (2)cd scripts/ado-script && npm run codegen: passed; no drift beyond thecommitted catalog v2 artifacts
npm test: passed (97 files, 1362 tests, including newpackage,resolve-feeds, and proxy e2e cases for each protocol, denial, view, slotisolation, and canary absence)
npm run typecheck: passednpm run build: passednpx vitest run src/compiler-smoke-e2e: passed (286)npm run build:compiler-smoke-e2e: passedgit diff --check: flags only README table rows that keep the file'sexisting CRLF endings
Not run: a live Azure DevOps pipeline against a real feed. That requires
provisioning; see
tests/smoke/REGISTERED.md.