feat(compile): runtime prompt loading via {{#runtime-import}} markers#625
feat(compile): runtime prompt loading via {{#runtime-import}} markers#625jamesadevine wants to merge 9 commits into
Conversation
🔍 Rust PR ReviewSummary: Has a critical cross-job VM isolation bug that will cause Findings🐛 Bugs / Logic Issues[CRITICAL]
Azure DevOps jobs run on separate, isolated VMs — - bash: |
set -eo pipefail
node '/tmp/ado-aw-scripts/ado-script/dist/import/index.js' /tmp/awf-tools/agent-prompt.md... The existing The automated tests don't catch this because they only verify that the compiled YAML contains the string [Minor] const expanded = original.replace(MARKER, (_whole, optional, rawPath) => {
if (!existsSync(absPath)) {
hadError ??= `file not found: ${rawPath}`;
return ""; // ← replaces required marker with empty string
}
// ...
});
// ...
if (hadError) { fail(hadError); }
writeFileSync(target, expanded, "utf8"); // ← already wrote above ← wait, no:Actually the
|
0116de3 to
8304c60
Compare
🔍 Rust PR ReviewSummary: Looks good — well-designed feature with strong test coverage. Three minor findings, none blocking. Findings
|
8304c60 to
97a2339
Compare
|
Thanks for the careful review — the critical cross-job VM isolation finding was spot on. Force-pushed the fix. What changed in this revision
Test pinning4 new placement tests in
On Net file shape
|
|
@copilot resolve the merge conflicts in this pull request |
Resolved by merging |
🔍 Rust PR ReviewSummary: Looks good overall — clean consolidation, correct per-job placement architecture, and solid test coverage. One functional bug to address, one minor observation. Findings🐛 Bugs / Logic Issues
|
…d ##vso injection Addresses three findings from the 2026-05-19 Rust PR review on #625: 1. Reject whitespace in agent source paths at compile time when inlined-imports is false. The runtime resolver (scripts/ado-script/src/import/index.ts) matches marker bodies with [^\s}]+, so a space silently truncated the path and produced either a misleading runtime `file not found` or, for optional markers, an unexpanded marker visible to the LLM. Mirrors the existing resolve_imports_inline guard. 2. Aggregate all import errors instead of reporting only the first. Previously hadError ??= captured one failure and silently swallowed subsequent ones. Now every missing/unreadable required marker emits its own ##vso[task.logissue type=error] line before exit(1). 3. Sanitize rawPath in ##vso error output. Strips `]`, CR, and LF from path strings embedded in diagnostic lines so an unusual path can no longer break the ##vso command framing. Tests: * tests/compiler_tests.rs::test_runtime_imports_default_rejects_source_path_with_whitespace * scripts/ado-script/src/import/__tests__/error-reporting.test.ts (2 cases) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Solid implementation — the consolidation into a single Findings🔒 Security Concerns
|
…rd fixture helper Two follow-up findings from the 2026-05-19 review on #625: 1. Reject `..` path components in both resolvers. `resolve_imports_inline` (compile-time, inlined-imports: true) and `import.js` (runtime, inlined-imports: false) both accepted `../`-style paths without restriction. A malicious markdown body on an untrusted PR branch could therefore embed host files (e.g. `{{#runtime-import ../../../../etc/passwd}}`) into the compiled YAML or, at runtime, into the agent prompt. The new guard rejects any path whose `/` or `\\`-split segments include `..`, regardless of whether the path is absolute or relative. Literal `..` characters inside a filename (e.g. `name..md`) are still allowed because they are not segments. 2. `compile_fixture_with_inlined_imports` now refuses fixtures that already declare `inlined-imports:`. The helper used to inject `inlined-imports: true` by raw string substitution before the closing `---`. If a future fixture hard-coded `inlined-imports: false`, the rewritten front matter would have two `inlined-imports:` keys; serde_yaml silently uses the last one so the test would still pass, but the duplicate-key fixture is confusing and the helper would silently flip the author's intent. The guard panics with an actionable message. Tests: * src/compile/extensions/ado_script.rs: 5 new unit tests covering relative/embedded/absolute/backslash `..` rejection and the literal `name..md` allow case. * scripts/ado-script/src/import/__tests__/path-traversal.test.ts: 4 new vitest cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Strong implementation with good security coverage — one stale doc addition that should be removed, one narrow missing validation worth noting. Findings🐛 Bugs / Logic Issues
|
…d ##vso injection Addresses three findings from the 2026-05-19 Rust PR review on #625: 1. Reject whitespace in agent source paths at compile time when inlined-imports is false. The runtime resolver (scripts/ado-script/src/import/index.ts) matches marker bodies with [^\s}]+, so a space silently truncated the path and produced either a misleading runtime `file not found` or, for optional markers, an unexpanded marker visible to the LLM. Mirrors the existing resolve_imports_inline guard. 2. Aggregate all import errors instead of reporting only the first. Previously hadError ??= captured one failure and silently swallowed subsequent ones. Now every missing/unreadable required marker emits its own ##vso[task.logissue type=error] line before exit(1). 3. Sanitize rawPath in ##vso error output. Strips `]`, CR, and LF from path strings embedded in diagnostic lines so an unusual path can no longer break the ##vso command framing. Tests: * tests/compiler_tests.rs::test_runtime_imports_default_rejects_source_path_with_whitespace * scripts/ado-script/src/import/__tests__/error-reporting.test.ts (2 cases) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rd fixture helper Two follow-up findings from the 2026-05-19 review on #625: 1. Reject `..` path components in both resolvers. `resolve_imports_inline` (compile-time, inlined-imports: true) and `import.js` (runtime, inlined-imports: false) both accepted `../`-style paths without restriction. A malicious markdown body on an untrusted PR branch could therefore embed host files (e.g. `{{#runtime-import ../../../../etc/passwd}}`) into the compiled YAML or, at runtime, into the agent prompt. The new guard rejects any path whose `/` or `\\`-split segments include `..`, regardless of whether the path is absolute or relative. Literal `..` characters inside a filename (e.g. `name..md`) are still allowed because they are not segments. 2. `compile_fixture_with_inlined_imports` now refuses fixtures that already declare `inlined-imports:`. The helper used to inject `inlined-imports: true` by raw string substitution before the closing `---`. If a future fixture hard-coded `inlined-imports: false`, the rewritten front matter would have two `inlined-imports:` keys; serde_yaml silently uses the last one so the test would still pass, but the duplicate-key fixture is confusing and the helper would silently flip the author's intent. The guard panics with an actionable message. Tests: * src/compile/extensions/ado_script.rs: 5 new unit tests covering relative/embedded/absolute/backslash `..` rejection and the literal `name..md` allow case. * scripts/ado-script/src/import/__tests__/path-traversal.test.ts: 4 new vitest cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
cf70ed2 to
baf94f5
Compare
🔍 Rust PR ReviewSummary: Looks good overall — solid implementation with thorough test coverage. Two findings worth addressing before merge. Findings🐛 Bugs / Logic Issues
The docs say:
But ADO_AW_IMPORT_BASE: $(Build.SourcesDirectory)For single-checkout (the common case), these are the same. For multi-checkout pipelines (where This could silently produce the wrong resolution for users with custom
The capture group The compile-time
|
1aeafb9 to
224e6ff
Compare
🔍 Rust PR ReviewSummary: Looks good overall — the consolidation into a single Findings🐛 Bugs / Logic Issues
|
e2317de to
e52865a
Compare
🔍 Rust PR ReviewSummary: Solid implementation with good security coverage. One logic-ordering concern in the 1ES path and two documented limitations worth making explicit. Findings
|
Adopts gh-aw's {{#runtime-import path}} marker model and the inlined-imports front-matter toggle. Agent prompt bodies (and the Stage-2 threat-analysis prompt) are now loaded at pipeline runtime by default; body edits no longer require ado-aw compile.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ser runtimes ADO's NodeTool@0 prepends to PATH, so when both AdoScriptExtension (gate+import bundle, requires Node 20.x) and NodeExtension (user-pinned version) emit NodeTool@0 into the same Agent job, the LAST install wins on PATH. Previously AdoScript ran in the Tool phase, AFTER Runtime — so its hardcoded 20.x silently overrode the user's pinned version. Introduces a new ExtensionPhase::System variant that sorts before Runtime. AdoScript moves to System: its NodeTool@0 install runs first, the resolver step uses it during a brief 20.x window, then the user's NodeExtension's NodeTool@0 runs and the user's version wins on PATH for everything after. Adds test_node_runtime_install_orders_after_ado_script_so_user_version_wins pinning this ordering. Updates extension-count tests for the new phase. Refreshes ExtensionPhase docs and the collect_extensions ordering policy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…d ##vso injection Addresses three findings from the 2026-05-19 Rust PR review on #625: 1. Reject whitespace in agent source paths at compile time when inlined-imports is false. The runtime resolver (scripts/ado-script/src/import/index.ts) matches marker bodies with [^\s}]+, so a space silently truncated the path and produced either a misleading runtime `file not found` or, for optional markers, an unexpanded marker visible to the LLM. Mirrors the existing resolve_imports_inline guard. 2. Aggregate all import errors instead of reporting only the first. Previously hadError ??= captured one failure and silently swallowed subsequent ones. Now every missing/unreadable required marker emits its own ##vso[task.logissue type=error] line before exit(1). 3. Sanitize rawPath in ##vso error output. Strips `]`, CR, and LF from path strings embedded in diagnostic lines so an unusual path can no longer break the ##vso command framing. Tests: * tests/compiler_tests.rs::test_runtime_imports_default_rejects_source_path_with_whitespace * scripts/ado-script/src/import/__tests__/error-reporting.test.ts (2 cases) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rd fixture helper Two follow-up findings from the 2026-05-19 review on #625: 1. Reject `..` path components in both resolvers. `resolve_imports_inline` (compile-time, inlined-imports: true) and `import.js` (runtime, inlined-imports: false) both accepted `../`-style paths without restriction. A malicious markdown body on an untrusted PR branch could therefore embed host files (e.g. `{{#runtime-import ../../../../etc/passwd}}`) into the compiled YAML or, at runtime, into the agent prompt. The new guard rejects any path whose `/` or `\\`-split segments include `..`, regardless of whether the path is absolute or relative. Literal `..` characters inside a filename (e.g. `name..md`) are still allowed because they are not segments. 2. `compile_fixture_with_inlined_imports` now refuses fixtures that already declare `inlined-imports:`. The helper used to inject `inlined-imports: true` by raw string substitution before the closing `---`. If a future fixture hard-coded `inlined-imports: false`, the rewritten front matter would have two `inlined-imports:` keys; serde_yaml silently uses the last one so the test would still pass, but the duplicate-key fixture is confusing and the helper would silently flip the author's intent. The guard panics with an actionable message. Tests: * src/compile/extensions/ado_script.rs: 5 new unit tests covering relative/embedded/absolute/backslash `..` rejection and the literal `name..md` allow case. * scripts/ado-script/src/import/__tests__/path-traversal.test.ts: 4 new vitest cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`import.js` consults `ADO_AW_IMPORT_BASE` only when resolving a relative marker path (`isAbsolute(rawPath) ? rawPath : resolve(base, rawPath)`). In the pipeline the only marker `import.js` ever sees is the compiler-generated top-level body marker, which embeds an absolute `$(Build.SourcesDirectory)/...` path. The resolver is also single-pass by design, so author-written nested relative markers inside the inlined body are never re-expanded at runtime. The env var was therefore dead code at runtime. Changes: - Remove the `env:` block from `resolver_step()` in `src/compile/extensions/ado_script.rs` (and update its unit test to assert the variable is absent). - Drop the `process.env.ADO_AW_IMPORT_BASE ??` fallback in `scripts/ado-script/src/import/index.ts`; `import.js` now always uses `dirname(argv[2])` for relative-path resolution (irrelevant in pipeline use, useful for local invocations). - Replace the `ADO_AW_IMPORT_BASE`-override vitest with a `dirname(target)` default-base test that pins the fallback for standalone callers. - Update `docs/ado-script.md` and `docs/runtime-imports.md` to drop the env-var contract and explain why the runtime never sees a relative marker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… on actual download
Two findings from the latest PR review:
1. docs/template-markers.md falsely claimed that the
`{{ threat_analysis_prompt }}` marker is emitted as a
`{{#runtime-import ...}}` when `inlined-imports: false`. The
threat-analysis prompt is tooling-shipped (compiled into the
`ado-aw` binary via `include_str!`) and unconditionally inlined at
step 11 of `compile_shared`. The marker is for the agent body, not
the threat prompt. Rewrote the paragraph to reflect this and to
cross-reference the rationale in `src/compile/common.rs`.
2. `AdoScriptExtension::required_hosts()` always requested
`github.com`, even when `inlined-imports: true` AND no filters were
configured (so neither `setup_steps()` nor `prepare_steps()` emits
the NodeTool@0 + curl pair, and github.com is therefore unreachable
from the pipeline). For a security-sensitive project, the
allowlist should match the actual network reach of the compiled
pipeline. Now returns `vec![]` unless `has_gate()` or
`runtime_imports_active()`. Added three unit tests covering all
three branches (no-consumer, gate-active, imports-active).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
e52865a to
211f498
Compare
🔍 Rust PR ReviewSummary: Looks good — solid architecture with one security gap worth addressing and a few minor observations. Findings🔒 Security Concerns
|
…solver
Three findings from the latest PR review:
1. **Security**: `resolve_imports_inline` accepted absolute paths
without restriction. When `inlined-imports: true`, an untrusted PR
branch could embed arbitrary host files into the compiled YAML via
`{{#runtime-import /home/runner/.ssh/id_rsa}}`,
`{{#runtime-import C:\Users\runner\secret.txt}}`, or
`{{#runtime-import \\server\share\file}}`. The PR description
already called out the `..`-traversal threat for "untrusted PR
branches" — the same threat applies here. Compile-time resolution
now requires a **relative** path rooted under `base_dir` (the
source `.md` file's parent directory, which is inside the repo and
therefore part of the same trust domain). `std::path::Path::is_absolute`
is platform-dependent, so the guard also explicitly checks
`/`-prefixed and `\\`-prefixed shapes.
`import.js` (runtime resolver) keeps its absolute-path support
unchanged — the agent VM is a lower-risk environment and the
pipeline-generated body marker is always absolute.
2. **Cleanup**: `AdoScriptExtension::has_gate()` and `setup_steps()`
both called `lower_pr_filters()` / `lower_pipeline_filters()`,
doing the same lowering twice. Factored into a single
`lowered_checks()` helper that both call sites reuse.
3. **Comment**: `sanitizeForVsoMessage` in `import.js` now explains
why `[` is intentionally NOT stripped (without a closing `]` and a
fresh `##vso` prefix it cannot open a new logging command).
Tests:
* `rejects_absolute_posix_path_at_compile_time`,
`rejects_absolute_windows_drive_path_at_compile_time`,
`rejects_unc_path_at_compile_time` in
`src/compile/extensions/ado_script.rs`.
* `supports_relative_and_absolute_paths` test removed (no longer
reachable behaviour); replaced with `supports_relative_path_resolution`.
* `docs/runtime-imports.md` updated to document the new restriction
and call out the security rationale.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Well-structured feature with good security analysis throughout — one functional gap worth clarifying and a minor regex asymmetry to note. Findings🐛 Bugs / Logic Issues
|
… regex `import.js`'s marker regex `[^\s}]+` excludes `}` from the path capture so the regex terminates cleanly at the closing `}}`. The compile-time resolver (`resolve_imports_inline`) terminated only at `}}` and would therefore silently accept a path like `foo}bar.md` that the runtime resolver would either truncate or leave unexpanded — a real compile-vs-runtime divergence. Reject `}` in paths up front at compile time so the failure mode is one clear compile error rather than two different runtime behaviours depending on `inlined-imports`. Added a comment to `import.js`'s regex documenting that the compile-time side enforces the same restriction. Test: `rejects_path_containing_closing_brace` in `src/compile/extensions/ado_script.rs`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…me-import `Path::is_absolute` is platform-dependent — on Linux it doesn't recognize Windows drive-letter paths like `C:\Users\...` as absolute, so `rejects_absolute_windows_drive_path_at_compile_time` failed on Linux CI with `file not found` instead of the expected absolute-path rejection. Added an explicit drive-letter detector that matches `[A-Za-z]:[/\\]` via pure string inspection, so the guard fires on every host where `ado-aw compile` may run regardless of the local platform's path semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Looks good overall — the architecture is clean, the security thinking is thorough, and the tests are excellent. One genuine security concern in the TypeScript runtime resolver deserves attention before merge. Findings🔒 Security Concerns
const absPath = isAbsolute(rawPath) ? rawPath : resolve(base, rawPath);The Rust compile-time resolver ( Author-written The if (isAbsolute(rawPath)) {
errors.push(`invalid path '${sanitizeForVsoMessage(rawPath)}': absolute paths are not allowed`);
return "";
}
|
Summary
Adopts gh-aw's
{{#runtime-import path}}marker model and theinlined-importsfront-matter toggle. Agent prompt bodies are now loaded at pipeline runtime by default, so edits to the markdown body no longer requireado-aw compile.Behaviour
inlined-imports: false(new default) — the agent body is loaded at pipeline runtime via a{{#runtime-import …}}marker resolved by a newimport.jsado-script bundle. The compiled YAML contains the marker (and the resolver step), not the body text.inlined-imports: true— legacy behaviour preserved bytes-for-bytes for the simple case; author-written{{#runtime-import shared/snippet.md}}markers are resolved at compile time so the body is fully self-contained.{{#runtime-import path}}(required) and{{#runtime-import? path}}(optional, skip-if-missing) work inside any agent's markdown body, with the same semantics as gh-aw.The Stage-2 threat-analysis prompt is not runtime-imported. It's a tooling-shipped template that's
include_str!'d into theado-awbinary and inlined into the emitted YAML at compile time, matching gh-aw's pattern (theirthreat_detection.mdships with the setup action and is read directly from disk — no marker, no resolver).Consolidated single extension (addresses reviewer feedback)
One always-on
AdoScriptExtensionowns all ado-script wiring. It exposes two features through the existing trait hooks — no new template markers, noScriptAssetsregistry:setup_steps()prepare_steps()ADO jobs use isolated VMs —
/tmpis not shared. The bundle is therefore downloaded once per consuming job. When both features are active, install + download steps appear in both Setup and Agent. That's correct architecture given ADO's topology, not waste.filters:inlined-importstruefalsetruefalseImplementation
src/compile/extensions/ado_script.rs(~470 lines). One internal helperinstall_and_download_steps()produces the install+download YAML; bothsetup_steps()andprepare_steps()call it. The Rust source has one place for the install/download YAML; the emitted YAML carries it once per consuming job.resolve_imports_inline()(compile-time resolver forinlined-imports: true) lives in the same module.src/compile/extensions/trigger_filters.rs,src/compile/extensions/runtime_prompt.rs,src/compile/script_assets.rs, thescript_assets: ScriptAssetsfield onCompileContextand its four constructor sites, the prepend block atcommon.rs:2123-2131, and the{{ agent_prompt_resolver_steps }}template marker (all four base templates).compile_sharednow detects whenextra_replacementsalready binds{{ setup_job }}(the 1ES path) and skips its own redundantgenerate_setup_jobinvocation. Each extension'ssetup_steps()is now invoked exactly once per pipeline.NodeTool@0displayName updated from"Install Node.js 20.x for gate evaluator"to"Install Node.js 20.x"(the bundle now serves both gate.js and import.js).docs/ado-script.md(per-job download model),docs/runtime-imports.md,docs/filter-ir.md,docs/template-markers.md,AGENTS.md.Test plan
Per-job placement tests (pin the reviewer-found bug)
4 new tests in
tests/compiler_tests.rsthat split the emitted YAML by job block and assert exactly which job(s) containDownload ado-aw scripts:test_gate_only_pipeline_downloads_bundle_in_setup_job_not_agenttest_imports_only_pipeline_downloads_bundle_in_agent_job_not_setuptest_both_features_active_downloads_bundle_in_both_jobstest_neither_feature_active_emits_no_node_or_download_anywhereWithout the consolidation, tests (2) and (3) fail with
"Agent job is missing the script bundle download"— exactly the cross-job VM isolation bug the reviewer caught.Automated
cargo buildclean.cargo test --bin ado-aw: 1595 unit tests pass.cargo test --tests: 108 compiler_tests + every integration suite green.cargo clippy --all-targets --all-featuresclean (only pre-existing warnings).cd scripts/ado-script && npm test: 199 tests across 26 files.cd scripts/ado-script && npm run test:smoke: 3/3 pass.cd scripts/ado-script && npm run typecheckclean.Manual
Hand-inspected emitted YAML for the four (gate × imports) combinations:
All four match the design.
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com