Skip to content

refactor(compile): structure generated ado-proxy and az wrapper scripts - #1875

Open
jamesadevine wants to merge 7 commits into
mainfrom
devinejames/structure-proxy-wrapper-scripts
Open

refactor(compile): structure generated ado-proxy and az wrapper scripts#1875
jamesadevine wants to merge 7 commits into
mainfrom
devinejames/structure-proxy-wrapper-scripts

Conversation

@jamesadevine

Copy link
Copy Markdown
Collaborator

Summary

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 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 survive format! 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 by shell_script!, with substitution restricted to a typed, shell-quoted prelude:

shell_script! {
    /// Greppable: search `mkfifo` and you land on the producer.
    START_ADO_PROXY {
        interpreter: Bash,
        bindings: [PROXY_CONTAINER, AGENT_TEMP],
        externals: [],
        fragments: [resolve_org],
        body: r#"
set -euo pipefail
# ado-aw:fragment resolve_org
PROXY_DIR=$(mktemp -d "$AGENT_TEMP/ado-proxy.XXXXXX")
docker inspect -f 'state={{.State.Status}}' "$PROXY_CONTAINER"
"#,
    }
}
  • One injection position. A value can only be the right-hand side of a prelude assignment, so it can never alter the structure of the script. Typed binders (text, number, boolean, words, ado_macro, ado_path, document) each validate their own shape.
  • Declared surface. Every variable a body reads is a binding (compiler-supplied) or an external (runtime-supplied). Enforced at render and by a registry-wide test.
  • Credentials cannot be bindings — the prelude is committed to the repo. They stay on EnvValue::secret, which ADO masks.
  • Single-hop editing. The shell stays in the file that produces it: grep the shell, land on the producer.

Issue #1833 specifics

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 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 -c entrypoint — previously an opaque string shellcheck could not see into — is now its own registered Sh script 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 az wrapper — several hundred lines of unlinted shell.

There are now two levels:

Level What it proves
src/compile/shell/lint.rs — every registered script in isolation the shell is correct
tests/bash_lint_tests.rs — bodies in compiled YAML the shell is emitted

A 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 .sh files with provenance headers.

Bugs found and fixed

Three of these were the tooling distorting the source rather than the source being wrong:

  • referenced_vars demanded a declaration for awk's $NF inside single quotes. An agent had renamed it to $n to comply; the original expression is restored and the checker now skips single-quoted spans.
  • PATH was stub-assigned, tripping SC2123 — a bug the real script does not have.
  • The export header pushed shebangs off line 1 (SC1128), so exported sh scripts were unrunnable. Caught only by shellchecking the exported files independently of the harness that generates them.
  • -v /tmp/ado-aw-lib:... duplicated the AZ_WRAPPER_DIR constant in the proxy container mount; now bound.

Scope note

Also migrated: the az wrapper, every extensions/ 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() and dedent() are deleted.

safe_outputs/create_pull_request.rs is deliberately not migrated. It has 38 \n\ continuations and no shell at all — they are Markdown PR-description text, and its 13 git calls are argv-based. Wrapping them would have added a shell that is not there today.

tests/generated_shell_guard.rs prevents regression, and a test proves the guard distinguishes an inline body from a format! 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 test2981 unit + all integration suites green
  • cargo clippy --all-targets — zero warnings, zero errors
  • cargo test --test bash_lint_tests — shellcheck over every bash body in compiled YAML
  • cargo test --bin ado-aw compile::shell — registry-wide shellcheck plus declared-surface, fragment-marker and shell-name guards
  • cargo test --test generated_shell_guard — regression guard
  • Independent verification: exported all 87 scripts and ran shellcheck on 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.
  • Credential custody: the 15 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).

jamesadevine and others added 6 commits August 10, 2026 21:22
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

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.

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
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.

refactor(compile): structure generated ado-proxy and az wrapper scripts

1 participant