feat(secrets): add --all-repos and --source via Pipeline Preview discovery#624
feat(secrets): add --all-repos and --source via Pipeline Preview discovery#624jamesadevine wants to merge 5 commits into
Conversation
…overy
Adds project-scope token management via two new flags on `secrets set`,
`secrets list`, and `secrets delete`:
- `--all-repos` — operate on every ado-aw pipeline ADO knows about in
the project (direct ado-aw definitions *and* consumer pipelines that
include ado-aw templates), regardless of which repo their root YAML
lives in.
- `--source <path>` — filter to consumers of one specific template.
Both flags activate a new Preview-driven discovery path that calls
`POST /_apis/pipelines/{id}/preview` per definition and scans the
expanded YAML for an `# ado-aw-metadata: {…}` JSON marker. The legacy
lexical local-fixture matcher remains the default; `--definition-ids`
remains the explicit-ID escape hatch.
To make discovery work, every compiled pipeline now carries a marker
via a new always-on `AdoAwMarkerExtension`. The marker lives inside a
bash Setup-job step because ADO's Preview API strips top-of-document
comments during YAML expansion (verified empirically against live def
2434 in `msazuresphere/4x4`) but preserves comments inside step
bodies. Uniform across all four targets (standalone / 1es / job /
stage); no per-target placement special-casing.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Solid overall — the three-layer design (marker extension → parse_marker_step → discovery) is clean and well-tested. A few concrete issues worth addressing before merge. Findings🐛 Bugs / Logic Issues
🔒 Security Concerns
|
- `is_direct_match`: return `false` (not `markers.is_empty()`) for the marker-less case. The previous code returned `true` for 0 markers, which would misclassify a non-ado-aw definition as `Direct` if any future caller hit that path. Belt-and-braces — the current callsite in `classify_definition` already guards against it. - `discovered_to_matched`: doc-comment claimed `UnknownRequiredParams` was propagated; implementation drops it. Keep the safer drop behaviour (we can't act on a marker-less definition) but surface a `warn!` summary from `resolve_definitions_via_discovery` so `secrets set --all-repos` operators can see when pipelines were skipped because of required-template-parameters / 403 / other preview failures. Doc comment updated to match. - `AdoContext::repo_url`: percent-encode `project` and `repo_name` so `DiscoveryScope::CurrentRepo` works for projects whose names contain reserved characters (e.g. spaces). The lowercase normalize step can't reconcile a decoded local name with the encoded form ADO returns in `repository.url`. - `AdoAwMarkerExtension`: bash-quote-escape the source path embedded in the runtime `echo` line. A markdown filename containing `'` (e.g. `agents/foo's.md`) would otherwise produce syntactically broken bash. New `bash_single_quote_escape` helper applies the canonical `'\''` idiom; the JSON marker line keeps the raw value because JSON has no quoting concern with `'`. Two new tests cover the idiom and a `foo's-agent.md` path. - `src/detect.rs`: drop the now-stale `#[allow(dead_code)]` attrs on `MARKER_STEP_PREFIX`, `MarkerMetadata`, and `parse_marker_step`. All three are actively consumed by `src/ado/discovery.rs`. - `resolve_for_command`: thread local-lock-file paths into discovery so the `process.yamlFilename` fast-path can skip Preview calls for locally-compiled pipelines. Best-effort scan — failures fall back to Preview-for-everything cleanly. All 1741 tests pass; clippy clean on touched files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Solid, well-tested feature addition — two real issues worth addressing before merge. Findings🔒 Security Concerns
🐛 Bugs / Logic Issues
|
- `AdoAwMarkerExtension`: neutralise `##vso[` and `##[` logging-command
prefixes in the source path before embedding it in the marker step's
runtime `echo` line. Without this, a markdown filename like
`agents/##vso[task.setvariable variable=FOO]value.md` would echo a
literal `##vso[...]` sequence that the ADO build agent's stdout
scanner treats as a task command — a logging-command-injection
primitive any attacker controlling a filename could trigger. New
`sanitize_for_vso_logging` helper mirrors the existing convention in
`crate::agent_stats::sanitize_for_markdown` (`[vso-filtered][` /
`[filtered][`). The `# ado-aw-metadata:` JSON line keeps the raw
value (it's a YAML comment, not echoed to stdout). Two new tests:
the sanitiser unit test and an end-to-end attack-payload roundtrip
asserting the echo line is neutralised.
- `resolve_definitions_via_discovery`: the previous skip-counter
implementation counted `UnknownRequiredParams` / `Forbidden` /
`PreviewFailed` failures *before* applying `source_filter`, so under
`--source agents/foo.md` the warnings would tell the user "N
definitions skipped requiring template parameters" for definitions
that had nothing to do with `agents/foo.md`. Split the counting:
* without `--source`: per-status counts are honest (we're operating
on every ado-aw pipeline) and the existing three warnings stand;
* with `--source`: a single conservative `uninspectable` counter,
surfaced as one warning that explicitly acknowledges we can't tell
whether any of those skipped definitions would have been consumers
of the filtered template.
- `src/ado/discovery.rs`: drop the file-level `#![allow(dead_code)]`.
`resolve_definitions_via_discovery` and `discovered_to_matched` are
now wired into `secrets.rs`; the suppression was hiding future
dead-code regressions. Build is clean without it.
- `src/main.rs` (`SecretsCmd`): clarified `--source` help text — calls
out that **without `--all-repos`, only the current repository is
searched**. Saves the user-confusion case "I ran `secrets set
GITHUB_TOKEN --source agents/foo.md` and got zero results" when
they're in a different repo than the consumer pipelines.
All 1743 tests pass; clippy clean on touched files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Solid design with good test coverage — a few targeted fixes needed before merge. Findings🐛 Bugs / Logic Issues
|
- `resolve_definitions_via_discovery`: normalize the user-supplied `--source` value through `normalize_source_path` before comparing against `marker.source`. The marker stores the canonical form (`agents/foo.md`), so without normalization a user typing `--source ./agents/foo.md` or `--source agents\foo.md` (Windows) silently matched nothing. Re-export `normalize_source_path` from `crate::compile` so callers outside the compile module tree can reach it cleanly. New test asserts the three common variants (canonical, leading-`./`, backslash) all produce the same normalized string. - `classify_definition` / `DiscoveryStatus::NotFound`: 404 from the Preview endpoint almost certainly means the definition was deleted between `list_definitions` and `preview_pipeline` (TOCTOU race). Previously routed through `PreviewFailed`, which inflated `skipped_failed` counts and confused operators. New `DiscoveryStatus::NotFound` variant is excluded from skip-warning counters in `resolve_definitions_via_discovery` and dropped by `discovered_to_matched` like the other non-actionable statuses. Debug-logged with the definition id+name so `--debug` users can still see what happened. - `DefinitionSummary::revision`: doc comment claimed Preview-driven discovery uses it as a cache key, but no caching is implemented. Rewrote to say it's deserialised for a future cache and there is *no* caching yet, with a clear "see the discovery module for current behaviour" pointer. - `DiscoveryScope::Explicit`: clarified the docstring to call out that no production callsite constructs this variant — `--definition-ids` uses the legacy `resolve_definitions` path before discovery ever runs. Variant is kept (not removed and not `#[cfg(test)]`-gated) because direct API consumers may want to feed pre-filtered IDs into discovery; the existing unit-test construction stays. - `secrets::resolve_for_command`: bail early with a targeted error when `--source` is used without `--all-repos` outside a recognised ADO repo. The previous behaviour was a generic "No ado-aw pipelines found via Preview-driven discovery" message that gave no hint that the empty result was caused by the missing git remote. New error spells out the cause and suggests `--all-repos`. All 1746 tests pass; clippy clean on touched files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Solid implementation — the security work is careful and the architecture is well-thought-out. A few items worth discussing before merge. Findings🐛 Bugs / Logic Issues
🔒 Security Concerns
|
- `normalize_repo_url`: percent-decode before comparing, so a project named "My Project" matches whether ADO returns the encoded form (`My%20Project`) or the decoded form. The previous implementation assumed ADO always returns percent-encoded URLs; that assumption is documented in code now and the comparison is encoding-independent. New unit tests cover the encoded/decoded equivalence and the case-insensitive/trailing-slash behaviour. - `discovered_to_matched`: stop silently truncating consumers that include multiple ado-aw templates. The `yaml_path` field used by `print_matched_summary` now joins every marker source with `, ` so e.g. `agents/a.md, agents/b.md` shows up honestly in the CLI summary. New unit test asserts both sources are surfaced. - `##vso[` defence-in-depth: the marker step's runtime echo already neutralises `##vso[` and `##[` prefixes, but the same raw source string was flowing through `MarkerMetadata` -> `MatchedDefinition::yaml_path` -> `print_matched_summary` (which writes to stdout). When the CLI is invoked from inside an ADO pipeline step, the agent's stdout scanner would still pick up an attacker-controlled `##vso[...]` payload. New `sanitize_for_vso_logging` helper in the discovery module applies the same convention (`##vso[` -> `[vso-filtered][`, `##[` -> `[filtered][`) when building the `yaml_path`. New unit test asserts the sanitisation. - `ADO_AW_PREVIEW_CONCURRENCY=0` now emits a `warn!` before clamping to 1, instead of silently masking the typo. Operators who set `=0` will see the warning and can correct the env value rather than wondering why their concurrency tuning had no effect. - New unit test for the `--source` + no-git-remote bail in `secrets::resolve_for_command`: previously the helpful "no Azure DevOps git remote was detected; try --all-repos" error path was untested. Now asserted via a `tokio::test` that constructs an empty AdoContext and verifies the error message contains both the cause and the suggested mitigation. All 1753 tests pass; clippy clean on touched files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Looks good overall — solid architecture, excellent test coverage, and careful attention to security. A few maintainability notes worth addressing before merge. Findings
|
- `is_direct_match`: return `false` (not `markers.is_empty()`) for the marker-less case. The previous code returned `true` for 0 markers, which would misclassify a non-ado-aw definition as `Direct` if any future caller hit that path. Belt-and-braces — the current callsite in `classify_definition` already guards against it. - `discovered_to_matched`: doc-comment claimed `UnknownRequiredParams` was propagated; implementation drops it. Keep the safer drop behaviour (we can't act on a marker-less definition) but surface a `warn!` summary from `resolve_definitions_via_discovery` so `secrets set --all-repos` operators can see when pipelines were skipped because of required-template-parameters / 403 / other preview failures. Doc comment updated to match. - `AdoContext::repo_url`: percent-encode `project` and `repo_name` so `DiscoveryScope::CurrentRepo` works for projects whose names contain reserved characters (e.g. spaces). The lowercase normalize step can't reconcile a decoded local name with the encoded form ADO returns in `repository.url`. - `AdoAwMarkerExtension`: bash-quote-escape the source path embedded in the runtime `echo` line. A markdown filename containing `'` (e.g. `agents/foo's.md`) would otherwise produce syntactically broken bash. New `bash_single_quote_escape` helper applies the canonical `'\''` idiom; the JSON marker line keeps the raw value because JSON has no quoting concern with `'`. Two new tests cover the idiom and a `foo's-agent.md` path. - `src/detect.rs`: drop the now-stale `#[allow(dead_code)]` attrs on `MARKER_STEP_PREFIX`, `MarkerMetadata`, and `parse_marker_step`. All three are actively consumed by `src/ado/discovery.rs`. - `resolve_for_command`: thread local-lock-file paths into discovery so the `process.yamlFilename` fast-path can skip Preview calls for locally-compiled pipelines. Best-effort scan — failures fall back to Preview-for-everything cleanly. All 1741 tests pass; clippy clean on touched files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
- `AdoAwMarkerExtension`: neutralise `##vso[` and `##[` logging-command
prefixes in the source path before embedding it in the marker step's
runtime `echo` line. Without this, a markdown filename like
`agents/##vso[task.setvariable variable=FOO]value.md` would echo a
literal `##vso[...]` sequence that the ADO build agent's stdout
scanner treats as a task command — a logging-command-injection
primitive any attacker controlling a filename could trigger. New
`sanitize_for_vso_logging` helper mirrors the existing convention in
`crate::agent_stats::sanitize_for_markdown` (`[vso-filtered][` /
`[filtered][`). The `# ado-aw-metadata:` JSON line keeps the raw
value (it's a YAML comment, not echoed to stdout). Two new tests:
the sanitiser unit test and an end-to-end attack-payload roundtrip
asserting the echo line is neutralised.
- `resolve_definitions_via_discovery`: the previous skip-counter
implementation counted `UnknownRequiredParams` / `Forbidden` /
`PreviewFailed` failures *before* applying `source_filter`, so under
`--source agents/foo.md` the warnings would tell the user "N
definitions skipped requiring template parameters" for definitions
that had nothing to do with `agents/foo.md`. Split the counting:
* without `--source`: per-status counts are honest (we're operating
on every ado-aw pipeline) and the existing three warnings stand;
* with `--source`: a single conservative `uninspectable` counter,
surfaced as one warning that explicitly acknowledges we can't tell
whether any of those skipped definitions would have been consumers
of the filtered template.
- `src/ado/discovery.rs`: drop the file-level `#![allow(dead_code)]`.
`resolve_definitions_via_discovery` and `discovered_to_matched` are
now wired into `secrets.rs`; the suppression was hiding future
dead-code regressions. Build is clean without it.
- `src/main.rs` (`SecretsCmd`): clarified `--source` help text — calls
out that **without `--all-repos`, only the current repository is
searched**. Saves the user-confusion case "I ran `secrets set
GITHUB_TOKEN --source agents/foo.md` and got zero results" when
they're in a different repo than the consumer pipelines.
All 1743 tests pass; clippy clean on touched files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
- `resolve_definitions_via_discovery`: normalize the user-supplied `--source` value through `normalize_source_path` before comparing against `marker.source`. The marker stores the canonical form (`agents/foo.md`), so without normalization a user typing `--source ./agents/foo.md` or `--source agents\foo.md` (Windows) silently matched nothing. Re-export `normalize_source_path` from `crate::compile` so callers outside the compile module tree can reach it cleanly. New test asserts the three common variants (canonical, leading-`./`, backslash) all produce the same normalized string. - `classify_definition` / `DiscoveryStatus::NotFound`: 404 from the Preview endpoint almost certainly means the definition was deleted between `list_definitions` and `preview_pipeline` (TOCTOU race). Previously routed through `PreviewFailed`, which inflated `skipped_failed` counts and confused operators. New `DiscoveryStatus::NotFound` variant is excluded from skip-warning counters in `resolve_definitions_via_discovery` and dropped by `discovered_to_matched` like the other non-actionable statuses. Debug-logged with the definition id+name so `--debug` users can still see what happened. - `DefinitionSummary::revision`: doc comment claimed Preview-driven discovery uses it as a cache key, but no caching is implemented. Rewrote to say it's deserialised for a future cache and there is *no* caching yet, with a clear "see the discovery module for current behaviour" pointer. - `DiscoveryScope::Explicit`: clarified the docstring to call out that no production callsite constructs this variant — `--definition-ids` uses the legacy `resolve_definitions` path before discovery ever runs. Variant is kept (not removed and not `#[cfg(test)]`-gated) because direct API consumers may want to feed pre-filtered IDs into discovery; the existing unit-test construction stays. - `secrets::resolve_for_command`: bail early with a targeted error when `--source` is used without `--all-repos` outside a recognised ADO repo. The previous behaviour was a generic "No ado-aw pipelines found via Preview-driven discovery" message that gave no hint that the empty result was caused by the missing git remote. New error spells out the cause and suggests `--all-repos`. All 1746 tests pass; clippy clean on touched files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
- `normalize_repo_url`: percent-decode before comparing, so a project named "My Project" matches whether ADO returns the encoded form (`My%20Project`) or the decoded form. The previous implementation assumed ADO always returns percent-encoded URLs; that assumption is documented in code now and the comparison is encoding-independent. New unit tests cover the encoded/decoded equivalence and the case-insensitive/trailing-slash behaviour. - `discovered_to_matched`: stop silently truncating consumers that include multiple ado-aw templates. The `yaml_path` field used by `print_matched_summary` now joins every marker source with `, ` so e.g. `agents/a.md, agents/b.md` shows up honestly in the CLI summary. New unit test asserts both sources are surfaced. - `##vso[` defence-in-depth: the marker step's runtime echo already neutralises `##vso[` and `##[` prefixes, but the same raw source string was flowing through `MarkerMetadata` -> `MatchedDefinition::yaml_path` -> `print_matched_summary` (which writes to stdout). When the CLI is invoked from inside an ADO pipeline step, the agent's stdout scanner would still pick up an attacker-controlled `##vso[...]` payload. New `sanitize_for_vso_logging` helper in the discovery module applies the same convention (`##vso[` -> `[vso-filtered][`, `##[` -> `[filtered][`) when building the `yaml_path`. New unit test asserts the sanitisation. - `ADO_AW_PREVIEW_CONCURRENCY=0` now emits a `warn!` before clamping to 1, instead of silently masking the typo. Operators who set `=0` will see the warning and can correct the env value rather than wondering why their concurrency tuning had no effect. - New unit test for the `--source` + no-git-remote bail in `secrets::resolve_for_command`: previously the helpful "no Azure DevOps git remote was detected; try --all-repos" error path was untested. Now asserted via a `tokio::test` that constructs an empty AdoContext and verifies the error message contains both the cause and the suggested mitigation. All 1753 tests pass; clippy clean on touched files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Summary
Adds project-scope token management to
ado-awvia two new flags on thesecretssubcommands:--all-repos— operate on every ado-aw pipeline ADO knows about in the project (direct ado-aw definitions and consumer pipelines that include ado-aw templates), regardless of which repo their root YAML lives in.--source <path>— filter to consumers of one specific template, e.g.agents/security-scan.md.Composing these solves the user-visible pain that motivated the work:
The legacy lexical local-fixture matcher remains the default;
--definition-idsremains the explicit-ID escape hatch.enable/disable/removekeep their existing source-scoped safety semantics and are intentionally not changed.How it works
Two pieces of new infrastructure make the above possible:
Always-on marker step (
src/compile/extensions/ado_aw_marker.rs). Every compiled pipeline now carries a# ado-aw-metadata: {schema,source,version,target}JSON line inside a Setup-job bash step.Why a bash step (and not a top-of-file comment): ADO's Pipeline Preview API strips top-of-document comments during YAML expansion — verified empirically against live def 2434 in
msazuresphere/4x4— but preserves comments inside step bodies (80 such lines survived the spike). The marker has to live inside a step body to survive Preview-driven discovery.Uniform across all four targets (
standalone/1es/job/stage); no per-target placement special-casing. Implemented via the existingCompilerExtension::setup_stepshook so it cleanly slots in alongsideGitHubExtensionandSafeOutputsExtensionincollect_extensions.Preview-driven discovery (
src/ado/discovery.rs). Newdiscover_ado_aw_pipelines(scope)enumerates project definitions, callsPOST /_apis/pipelines/{id}/previewper definition (8-permit semaphore, env-tunable viaADO_AW_PREVIEW_CONCURRENCY), and scans the response'sfinalYamlfor marker steps via the existingcrate::detect::parse_marker_step. Definitions whoseprocess.yamlFilenamematches a local lock file take a fast path that parses the local header directly and skips the Preview call.DiscoveryStatusclassifies each definition asDirect/Consumer/UnknownRequiredParams/UnknownForbidden/PreviewFailed(_)/NotAdoAw. Pure scope filtering and classification logic are factored out for unit-testing without HTTP.Files
src/compile/extensions/ado_aw_marker.rs(new) — the always-on marker extension.src/ado/discovery.rs(new) — Preview client,DiscoveryScope,DiscoveryStatus,discover_ado_aw_pipelines, adapters intoMatchedDefinition.src/detect.rs— addsMarkerMetadata+parse_marker_step. Existing# @ado-awparser unchanged.src/compile/extensions/mod.rs— registersAdoAwMarkerExtensionas always-on;CompileContextgainsinput_path.src/compile/{standalone,onees,common}.rs— passinput_pathtoCompileContext::new; 1ESgenerate_setup_jobnow routes extension setup_steps (was previously the only target that didn't).src/compile/types.rs—CompileTarget::as_str().src/compile/common.rs— extractednormalize_source_pathhelper shared by header comment and marker extension.src/ado/mod.rs—DefinitionSummarygainsrepository+revision; newRepositorystruct; newMatchMethod::Discovery.src/secrets.rs,src/main.rs—--all-repos/--sourceflag plumbing;resolve_for_commandchooses between legacy and discovery paths.docs/cli.md— documents the new flags and the project-scope-discovery section.src/enable.rs/src/list.rs(extending thedef(...)helpers for the newDefinitionSummaryfields).Empirical grounding
Used
az-acquired bearer auth to run a live spike againstmsazuresphere/4x4def 2434 (OS Release Readiness) before settling on the marker design. The spike confirmed:POST /_apis/pipelines/2434/preview?api-version=7.1-preview.1works end-to-end and returns 56 KB of expanded YAML infinalYaml.# This file is auto-generated by ado-aw…and# @ado-aw source="…" version=…are not in the expanded YAML (header is stripped during expansion).#comment lines inside step bodies are preserved verbatim.That finding mandated moving the marker from a top-of-file comment into a step body, which this PR implements.
Test plan
cargo test: 1739 passed, 0 failed.cargo clippy --all-targets --all-features: zero new warnings (only pre-existing baseline warnings remain).parse_marker_step(single / multiple / malformed JSON / forward-compat unknown fields / no-match / prefix-only-without-JSON).tests/compiler_tests.rsassert the marker step appears with correct source / version / target / schema for every target.src/ado/discovery.rsfor scope filtering,Direct/Consumerclassification, and the local-lock fast-path lookup table.secrets list --definition-ids 2434againstmsazuresphere/4x4confirms the legacy path still works.secrets list --all-repos --source agents/release-readiness.md --debugconfirms the discovery path correctly fans outPOST /_apis/pipelines/{id}/previewcalls to every definition in the project. (Returns no match for def 2434 because that definition was compiled before this PR; resolves once the dogfood recompile follow-up lands.)Follow-up
Pre-marker definitions remain findable only via the
process.yamlFilenamefast-path until they're recompiled with this PR'sado-aw. A separate PR will runado-aw compileagainst every dogfood pipeline and push the regenerated lock files. Tracked as a known follow-up; no flag day required because the legacy match path stays as a fallback.