refactor(compile): structure generated ado-proxy and az wrapper scripts - #1875
Open
jamesadevine wants to merge 7 commits into
Open
refactor(compile): structure generated ado-proxy and az wrapper scripts#1875jamesadevine wants to merge 7 commits into
jamesadevine wants to merge 7 commits into
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).