refactor(compile): structure generated ado-proxy and az wrapper scripts - #1875
Conversation
Generated shell was built with `format!`, which forced three layers of
escaping onto every script: `\n\` continuations to fake multi-line
source, doubled braces to survive `format!` itself (so a Docker
Go-template read `{{{{.State.Status}}}}`), and an escaped quote for every
quoted word. A 200-line body written that way is not reviewable as shell,
and reviewing it as shell is the only way to know it is correct.
Add `src/compile/shell/`. A script is a raw-string const written exactly
as it will run, registered by the `shell_script!` macro, with
substitution restricted to a typed, shell-quoted prelude. A value can
only land as the right-hand side of an assignment, so it cannot alter the
structure of the script; the typed binders validate shape at render time.
Registration via `inventory` makes the script set enumerable without
compiling a pipeline, which closes the reachability gap in the existing
bash lint: a generator that no fixture happened to exercise was linted by
nothing. `ado-aw export-bash-scripts` materialises the same set as files
for review.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: aa46b482-6121-45e2-8278-1a9465b4f73e
Closes #1833. `start_ado_proxy_step` was a ~200-line body built by `format!`, and `render_az_wrapper` a ~120-line interpolated standalone executable. Neither was reviewable as shell, and neither was reachable by the bash lint — coverage was a function of fixture reachability, so a generator no fixture exercised was linted by nothing. Both now go through `ShellScript`. `start_ado_proxy_step` is decomposed into eight registered phases (work directory, policy, material minting, material assembly, container start, handover, destruction, readiness) spliced into an outline at `# ado-aw:fragment` markers. It remains a single Bash task: the phases are Rust-level and shell-level structure, not additional ADO steps, so the bearer, CA private key and leaf keys still never touch a runner path, argv, environment or container layer. The container's nested `sh -c` entrypoint is registered as its own `Sh` script and is therefore linted for the first time. A composed script is linted with its phases spliced in, so cross-phase variable flow — the one new risk decomposition introduces — is checked. Two fixes to the lint itself, both cases of the tool distorting the source rather than the source being wrong: * `referenced_vars` now skips single-quoted spans. It was demanding a declaration for awk's `$NF`, which had been renamed to `$n` to satisfy it; the original expression is restored. * Shell-provided variables (`PATH`, `HOME`, …) are no longer stub- assigned. Assigning `PATH` trips SC2123, reporting a bug the real script does not have. An `$(…)` binding carries a targeted `# shellcheck disable=SC2016`: the single quotes are the point, since ADO substitutes the macro before bash runs and the quoting keeps the result literal. Also migrated: the `az` wrapper, every `extensions/` generator, the four runtimes, `cache_memory`, `common.rs`, `filter_ir.rs` and `engine.rs`. Substring assertions that a literal appears somewhere are replaced by assertions that the producer bound it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa46b482-6121-45e2-8278-1a9465b4f73e
The provenance header was written above the shebang, so every exported `sh` script was unrunnable as a file. Shellcheck reports it as SC1128 when the exported file is checked directly, which is precisely what the export exists to enable. Found by shellchecking the exported files independently of the in-process harness — worth doing, because a harness that generates and then checks its own input can agree with itself while both are wrong. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa46b482-6121-45e2-8278-1a9465b4f73e
…cript
Completes the migration begun in f53f6d3e. Every `bash:` step the
compiler emits is now a registered, independently shellchecked script:
85 in total, up from 50.
`agentic_pipeline.rs` accounts for most of it — the MCPG lifecycle, log
copying, prompt preparation, threat analysis, safe-output execution and
the conclusion reporter. Four call sites remain on the old `bash()`
helper because their bodies come from payload helpers shared with
`extensions/ado_script.rs`; migrating them means changing a helper
signature across a module boundary, so `bash()` and `dedent()` stay for
now.
Two improvements fell out of making the shell legible enough to lint:
* The AWF invocations are built as bash arrays (`AWF_ARGS=(…)` /
`AWF_ARGS+=(…)`) rather than backslash-continuation chains with
fragment markers interleaved. Shellcheck could not follow a
continuation interrupted by a comment; the array form needs no
suppression and says what it means.
* `REVOKE_GITHUB_APP_TOKEN` moves from a fragment to bindings plus a
`${API_URL:+--api-url "$API_URL"}` guard. The old shape left a
dangling `\` at end of file when no api-url was configured — valid
POSIX, but only by accident.
Three suppressions are added, each targeted at one line with a stated
reason: SC2207 where an ADO macro is deliberately word-split into an
array, and SC2086 where `Binding::words` values expand unquoted, which
is that binding's documented contract.
Note on the earlier survey: counting `\n\` continuations overstated how
much shell was left. Most of those lines are Rust markdown and error
text — `create_pull_request.rs` has 38 of them and no shell at all, and
its git calls are argv-based, so wrapping them would have *added* a
shell that is not there today. Left alone deliberately.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: aa46b482-6121-45e2-8278-1a9465b4f73e
The last four `bash()` call sites went through two payload helpers, `stage_candidate_artifact_payload_bash` and `extract_package_payload_bash`, each carrying a SAFETY comment warning that every parameter is interpolated into a shell body with no escaping and that callers must pass only compiler-owned constants. That is precisely the hazard `ShellScript` removes, so both are now registered scripts with typed bindings and a `tail` fragment. The comments are gone because the property is now enforced rather than requested. Both keep their `-> String` signature: `extensions/ado_script.rs` shares `stage_candidate_artifact_payload_bash` and wraps it differently. Adds `Binding::ado_path` for the shape these need — a path built around an ADO variable, e.g. `$(Pipeline.Workspace)/agentic-pipeline-compiler`. `ado_macro` takes a bare name and rightly refused it. Rather than widening `text` to allow `$(`, which would have let an arbitrary command substitution through, `ado_path` validates that every embedded `$(…)` is a well-formed predefined-variable name. The value can therefore only expand to something Azure DevOps substitutes before bash runs, never to a command the runner executes. `bash()` and `dedent()` are deleted. Every shell body the compiler emits is now a registered script: 87 in total, all passing shellcheck both in-process and as exported standalone files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa46b482-6121-45e2-8278-1a9465b4f73e
`src/compile/shell/` makes generated shell reviewable and lintable, but
nothing stopped a future change from writing
`BashStep::new("X", format!("set -eu\n\ …"))` again. That shape is
invisible to both linters: the registry lint only sees registered
scripts, and the compiled-YAML lint would report a finding but never the
shape.
The guard checks three things: the script argument of `BashStep::new` is
never built inline, a `shell_script!` body contains no escaped
continuation, and neither retired helper comes back.
It deliberately does **not** grep for `\n\` across the codebase. The
earlier survey in this work used exactly that as a proxy for "how much
shell is left" and was badly wrong — most such lines are Rust markdown
and error text, and `safe_outputs/create_pull_request.rs` has 38 of them
and no shell at all. Acting on that count would have meant wrapping
argv-based `git` calls in a shell that is not there today.
Parsing the call by balancing parentheses matters for the same reason:
the first draft scanned for a terminator, overran the call, and reported
a `format!` in the next call's *display name* as a shell body. A test
exercises the discriminator on both shapes, since a guard that only ever
passes is indistinguishable from one that does nothing.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: aa46b482-6121-45e2-8278-1a9465b4f73e
|
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. |
CI caught three assertions that still expected shell values interpolated inline. The PR moves them into the generated binding prelude, so the emitted text legitimately changed: * `tests/compiler_tests.rs` — the Azure DevOps MCP version (from #1843) now renders as `MCP_VERSION='2.9.0'` with `"$MCP_PACKAGE@$MCP_VERSION"` rather than `"@azure-devops/mcp@2.9.0"`. Both the override and default cases now assert on the prelude *and* the use, which also proves the install and its verification read one version rather than two. * `tests/gate_e2e.rs` — `find_gate_spec` located the gate step by the literal `node '<path>'`. With the path bound, that matched nothing and the test reported the gate as absent rather than as changed. Also passes `SYSTEMROOT` through the gate harness's `env_clear` on Windows. Node aborts during initialisation without it — its CSPRNG seeding resolves the OS crypto provider relative to that variable — and the failure surfaces as an assertion with empty stdout and a native stack trace, which reads like a gate-logic bug. Linux CI never hit this; it made the test unrunnable locally, which is how the stale locator survived review in the first place. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa46b482-6121-45e2-8278-1a9465b4f73e
|
/review |
|
✅ Rust Code Quality Reviewer completed the Rust code quality review. |
There was a problem hiding this comment.
Warning
Threat Detection Engine Failure — The analysis engine could not complete. This is a tooling failure, not a security finding.
What happened
The threat detection results could not be parsed.
Review the workflow run logs for details.
🧪 Test quality analysis by Test Quality Sentinel · auto · 67.2 AIC · ⊞ 11.1K
Comment /review to run again
There was a problem hiding this comment.
Reviewed the Rust engineering quality of the src/compile/shell/ refactor and its call-site migrations (agentic_pipeline.rs, az_wrapper.rs, azure_cli.rs, ado_script.rs, exec_context/, filter_ir.rs, runtimes/, generated_shell_guard.rs, and the shell/ module itself).
Verdict: COMMENT — no merge-blocking defects found. The migration is disciplined: Binding construction is well-validated (injection-safety unit tests are thorough), all new unwrap()/expect() calls are confined to test code or documented internal-invariant panics, and the mechanical format! → ShellScript conversions I spot-checked are behaviorally equivalent to what they replace.
Notes
- One inline comment posted: a possible pipe deadlock in
shell/lint.rs'scheck()(writes to child stdin before reading output) — currently low-risk since scripts are small, but worth hardening given a lint-only failure mode of a hung CI job is a bad debugging experience. - The
rust-criticsub-agent I started in Step 1 returned no usable output on two reads; per the review contract I discarded it and relied on my own manual pass. - Did not review ado-aw-specific concerns (front-matter schemas, generated YAML shape, safe-output/bundle drift) — that's the Compiler Contract Reviewer's remit.
🦀 Rust code quality review by Rust Code Quality Reviewer · auto · 151.9 AIC · ⌖ 2.17 AIC · ⊞ 11.2K
Comment /review to run again
|
@copilot merge main |
…-proxy-wrapper-scripts Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
|
/review |
|
✅ Rust Code Quality Reviewer completed the Rust code quality review.
|
There was a problem hiding this comment.
Test-quality pass over the shell-refactor PR. Most of the ~5,700 lines of new/changed test code are solid: the new src/compile/shell/ module (bindings.rs, mod.rs, registry.rs, lint.rs) is thoroughly unit-tested with meaningful negative cases (should_panic on credentials, command substitutions, unterminated macros, undeclared bindings/fragments), and tests/generated_shell_guard.rs includes a self-test proving its own discriminator (the_guard_catches_an_inline_body_but_not_a_formatted_display_name) — a good practice that's rare to see. The gate_e2e.rs and ado_aw_marker.rs test updates correctly track the underlying binding changes rather than just loosening assertions to compile.
One finding: in src/compile/az_wrapper.rs, advertises_only_what_the_policy_actually_grants and rest_stays_available_whatever_the_capabilities replaced space-padded substring checks (" repos ", !contains(" {absent} ")) with bare .contains("repos") / .contains(absent) against the same space-joined ALLOWED_GROUPS string. This drops word-boundary protection the original test relied on to guard against one group name being a substring of another — see inline comment.
No missing-test gaps or weakened assertions found elsewhere in the diff.
🧪 Test quality analysis by Test Quality Sentinel · auto · 75.5 AIC · ⌖ 2.25 AIC · ⊞ 11.2K
Comment /review to run again
There was a problem hiding this comment.
Security review: no regressions found
This PR refactors ~200-line format!-built shell (ado-proxy startup, az wrapper) into a typed src/compile/shell/ module (ShellScript + Binding). Reviewed for security-relevant behavior change:
- Injection surface:
Bindingconstructors (text/words/ado_macro/ado_path/document) validate and single-quote every substituted value, closing off the exact positionsformat!interpolation used to smuggle command substitution or metacharacters.ado_pathexplicitly rejects`and un-validated$(...). - Credential handling unchanged:
SC_READ_TOKEN/SC_WRITE_TOKEN/SYSTEM_ACCESSTOKEN/GITHUB_TOKEN/etc. still flow only throughwith_env(..., EnvValue::secret(...)), never through the new binding/prelude channel.Bindingconstructors assert-panic if a value mentions a secret name (assert_not_secret), which is a new defense-in-depth check that did not exist before. - Fragments (verbatim spliced shell, e.g.
resolve_ado_organization_bash, the az wrapper body) are compiler-authored constants, not user/agent-controlled input, so the lack of a secret scan on fragment text is not a regression — same trust level as theformat!body it replaces. - No changes to
src/validate.rs,src/sanitize.rs, orsrc/secure.rs— no validation logic was touched, widened, or removed. - Token projection points (
ado_bundle.rs,token_source_for) are untouched by this diff. - Values like
awf_paths/pipeline template paths remain compiler-internal (not agent/PR-controlled), so routing them throughBinding::document/heredoc is a readability improvement, not a new trust boundary.
Overall this is a security-neutral (net-positive: adds assert_not_secret guard) internal refactor with extensive new tests (shellcheck-every-script lint, fragment-marker drift checks). No exploitable injection, token-scope, or validation regressions found in the diff.
🔒 Security review by PR Security Reviewer · auto · 60.4 AIC · ⌖ 4.53 AIC · ⊞ 11.6K
Comment /review to run again
There was a problem hiding this comment.
Compiler Contract Review — no blocking findings
Reviewed this refactor against ado-aw's compiler contracts (front-matter grammar, safe-output tools, typed IR, generated shell, extensions, docs sync). This is a clean, well-scoped, thoroughly self-documenting refactor.
Drift checks — all clear:
- No
.github/workflows/*.mdchanged without its.lock.yml. scripts/ado-script/src/**was not touched, so no bundle-drift concern.filter_ir.rschanges are mechanical IR-step return-type adjustments, notFact/gate-schema changes — no codegen drift.- No
tests/safe-outputs/lock files added.
Contract checks:
- New
src/compile/shell/module (bindings/registry/lint/export/mod) is fully documented inAGENTS.md(tree entry + description),docs/extending.md(new "Generated shell scripts" section),docs/cli.md(export-bash-scripts), and the root README-equivalent testing section — all four required touch points are present. - The new
Bindinginjection-position design (single RHS-of-assignment substitution, credential-name rejection viaSECRET_NAMES,ado_macro/ado_pathrestricted to dotted-name macros) is a genuine security improvement over the priorformat!-built shell, and is backed by targetedshould_panictests for each rejection path. tests/generated_shell_guard.rsis a real enforcement mechanism (not just documentation) — it greps forBashStep::newcalls with an inlineformat!/\n\script body and fails the build.- No raw
Stringidentifier fields were introduced inParams/safe-output types in this diff.
Verification performed:
cargo test --bin ado-aw compile::shell— 58/58 pass, includingevery_registered_script_passes_shellcheck.cargo test --test generated_shell_guard— 4/4 pass.cargo test --test bash_lint_tests— 2/2 pass (compiled-YAML shellcheck).cargo test --bin ado-aw(full unit suite) — 2993/2993 pass.cargo test --test compiler_tests --test gate_e2e— 222/222 pass.
No merge-blocking issues found.
Warning
Firewall blocked 1 domain
The following domain was blocked by the firewall during workflow execution:
spsprodeus21.vssps.visualstudio.com
To allow these domains, add them to the network.allowed list in your workflow frontmatter:
network:
allowed:
- defaults
- "spsprodeus21.vssps.visualstudio.com"See Network Configuration for more information.
🏗️ Compiler contract review by Compiler Contract Reviewer · auto · 100.8 AIC · ⌖ 1.99 AIC · ⊞ 12.7K
Comment /review to run again
There was a problem hiding this comment.
Rust code quality review
No merge-blocking Rust defects found in the changed lines. This PR mechanically migrates hand-built format!/heredoc shell scripts to the new typed ShellScript/Binding registry (src/compile/shell/), and the migration is unusually careful: every conversion I checked preserves exact runtime behavior, keeps the credential-custody contract intact (bearer via env: only, private material streamed via stdin/FIFO, destroyed before polling in start_ado_proxy_step), and is backed by tests that assert on the new prelude/binding shape rather than loosening coverage.
💡 Areas checked
src/compile/shell/{bindings,mod,registry,lint}.rs(new) —Bindingconstructors validate metacharacters/whitespace/secrets before shell-quoting;all_scripts()explicitly sorts for determinism instead of relying oninventorylink order.src/engine.rs,src/compile/az_wrapper.rs,src/compile/common.rs— refactored install/wrapper/org-resolution steps produce equivalent scripts to the priorformat!versions.src/compile/extensions/ado_script.rs,azure_cli.rs— bundle download/mint/revoke/prepare-pr-base steps correctly route paths and API URLs through bindings, including the${API_URL:+...}empty-value guard.src/compile/agentic_pipeline.rs::start_ado_proxy_step(security-critical) — the FIFO material handover,LEAF_HOSTSword-list binding (validated,shellcheck disable=SC2086correctly documented for the intentional unquoted word-split), and stdin-only bearer custody are preserved verbatim.
Note: the rust-critic sub-agent I started produced no usable output after two poll cycles (idle with empty response both times); its findings are discarded per the review contract, and this review reflects only my own manual pass.
🦀 Rust code quality review by Rust Code Quality Reviewer · auto · 175.2 AIC · ⌖ 9.3 AIC · ⊞ 11.4K
Comment /review to run again
|
/azp run ado-aw candidate compiler smoke |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f166231-06c9-4f21-b09a-3ea5117a2883
|
/azp run ado-aw candidate compiler smoke |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f166231-06c9-4f21-b09a-3ea5117a2883
|
/azp run ado-aw candidate compiler smoke |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f166231-06c9-4f21-b09a-3ea5117a2883
Summary
Closes #1833.
start_ado_proxy_stepwas a ~200-line body built byformat!, andrender_az_wrappera ~120-line interpolated standalone executable. Neither was reviewable as shell — and reviewing them as shell is the only way to know they are correct.The unreadability came from the escaping, not from bash:
\n\continuations to fake multi-line source, doubled braces to surviveformat!itself (a Docker Go-template read{{{{.State.Status}}}}), and an escaped quote for every quoted word. So bash stays; the escaping goes.The approach
A new
src/compile/shell/module. A script is a raw-string const co-located with its producer, registered byshell_script!, with substitution restricted to a typed, shell-quoted prelude:text,number,boolean,words,ado_macro,ado_path,document) each validate their own shape.binding(compiler-supplied) or anexternal(runtime-supplied). Enforced at render and by a registry-wide test.EnvValue::secret, which ADO masks.Issue #1833 specifics
start_ado_proxy_stepis decomposed into eight registered phases (work directory, policy, material minting, material assembly, container start, handover, destruction, readiness) spliced into an outline at# ado-aw:fragmentmarkers.It remains one atomic Bash task. The phases are Rust-level and shell-level structure, not additional ADO steps, so the bearer, CA private key and leaf keys still never touch a runner path, argv, environment or container layer. All 15 credential-custody tests pass unchanged.
The container's nested
sh -centrypoint — previously an opaque string shellcheck could not see into — is now its own registeredShscript and is linted for the first time.Lint coverage was the real gap
Coverage used to depend on fixture reachability: a generator no fixture exercised was linted by nothing. That was exactly the ado-proxy lifecycle and the
azwrapper — several hundred lines of unlinted shell.There are now two levels:
src/compile/shell/lint.rs— every registered script in isolationtests/bash_lint_tests.rs— bodies in compiled YAMLA composed script is linted with its phases spliced in, so cross-phase variable flow — the one new risk decomposition introduces — is checked rather than assumed.
ado-aw export-bash-scripts --output <dir>materialises all 87 scripts as reviewable.shfiles with provenance headers.Bugs found and fixed
Three of these were the tooling distorting the source rather than the source being wrong:
referenced_varsdemanded a declaration for awk's$NFinside single quotes. An agent had renamed it to$nto comply; the original expression is restored and the checker now skips single-quoted spans.PATHwas stub-assigned, tripping SC2123 — a bug the real script does not have.shscripts were unrunnable. Caught only by shellchecking the exported files independently of the harness that generates them.-v /tmp/ado-aw-lib:...duplicated theAZ_WRAPPER_DIRconstant in the proxy container mount; now bound.Scope note
Also migrated: the
azwrapper, everyextensions/generator, the four runtimes,cache_memory,common.rs,filter_ir.rs,engine.rs, and the supply-chain payload staging helpers (which carried explicit "SAFETY: unescaped interpolation" comments — now enforced rather than requested).bash()anddedent()are deleted.safe_outputs/create_pull_request.rsis deliberately not migrated. It has 38\n\continuations and no shell at all — they are Markdown PR-description text, and its 13gitcalls are argv-based. Wrapping them would have added a shell that is not there today.tests/generated_shell_guard.rsprevents regression, and a test proves the guard distinguishes an inline body from aformat!display name — a guard that only ever passes is indistinguishable from one that does nothing.Test plan
All run locally against shellcheck 0.10.0 with
ENFORCE_BASH_LINT=1:cargo test— 2981 unit + all integration suites greencargo clippy --all-targets— zero warnings, zero errorscargo test --test bash_lint_tests— shellcheck over every bash body in compiled YAMLcargo test --bin ado-aw compile::shell— registry-wide shellcheck plus declared-surface, fragment-marker and shell-name guardscargo test --test generated_shell_guard— regression guardshellcheckon them as standalone files, outside the in-process harness — 0 findings. This is what caught the shebang bug; a harness that generates and then checks its own input can agree with itself while both are wrong.ado_proxy_*tests (bearer/CA-key never under/tmp, stdin-only handover, key destruction after handover, single-task startup) all pass unchanged.Emitted YAML bytes change (a binding prelude is added and literals become variable references); behaviour and step ordering do not. Rebased onto
main, resolving conflicts with #1858 (hidden export-command docs) and #1843 (Azure DevOps MCP version override — its override path is preserved and still asserted).