ado-script is the umbrella name for the TypeScript workspace at
scripts/ado-script/. It produces small,
ncc-bundled Node programs that the compiler injects into every emitted
pipeline as runtime helpers. The current bundles are:
gate.js— the trigger-filter gate evaluator (Setup job).prompt.js— the agent prompt renderer (Agent job). Reads the agent.mdfrom the workspace at runtime, strips its front matter, runs single-pass variable substitution, and writes the rendered prompt for the AWF sandbox. See Whatprompt.jsdoes below.
Internal-only.
ado-scriptis not a user-facing front-matter feature. Authors never write anado-script:block in their agent markdown. The compiler decides when anado-scriptbundle is needed and how to wire it. Seedocs/tools.mdfor what is user-facing. The one user-visible knob isinlined-imports: truewhich opts back into the legacy compile-time prompt-embedding behaviour and skipsprompt.js.
gate.js is a single-shot Node program that runs as a step in the
pipeline's Setup job and decides whether the downstream Agent /
SafeOutputs jobs should execute. It evaluates a declarative GateSpec
against runtime facts (PR title, labels, changed files, build reason,
etc.) and emits exactly one ##vso[task.setvariable] line:
##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true (or false)
Downstream jobs gate themselves on that variable via a condition:
clause emitted by the compiler.
The gate is a data interpreter, not a code evaluator. The GateSpec
is a typed JSON document; predicates are dispatched via a switch on a
discriminated union. There is no eval, no Function, no vm — a
compromised compiler cannot use the spec to run arbitrary code on the
pipeline runner.
┌──────────────────────┐
│ Rust compiler │
│ (filter_ir.rs) │
└──────────┬───────────┘
│ build_gate_spec(...) → GateSpec (JSON, base64)
▼
┌──────────────────────┐
│ Generated pipeline │
│ Setup job: │
│ 1. NodeTool@0 │
│ 2. curl + sha256 │ downloads ado-script.zip
│ + unzip │ from the matching ado-aw release
│ 3. node gate/index │ reads GATE_SPEC env var
│ .js │
└──────────┬───────────┘
│ ##vso[task.setvariable variable=SHOULD_RUN;…]
▼
┌──────────────────────┐
│ Agent / SafeOutputs │ conditioned on SHOULD_RUN=true
│ jobs │
└──────────────────────┘
The same GateSpec shape is generated as a JSON Schema by
cargo run -- export-gate-schema and converted to TypeScript by
json-schema-to-typescript into src/shared/types.gen.ts. The TS
gate evaluator imports from types.gen.ts, never from a hand-written
mirror of the IR — so the spec contract cannot drift between compiler
and evaluator. CI enforces this with a git diff --exit-code step on
the codegen output.
gate.js's entry point is src/gate/index.ts. It runs five stages,
all single-shot, all fail-closed on error:
- Decode + size-cap — base64-decode
GATE_SPEC, reject if the decoded JSON exceedsMAX_SPEC_DECODED_BYTES(256 KiB), thenJSON.parse. - Pre-flight validation — walk the predicate tree and throw on
any unknown
typediscriminant. This catches version drift between a newer compiler and an older bundledgate.jsbefore fact acquisition runs, so the failure mode is "loud" rather than "silent skip when the dependent fact is unavailable". Deliberately runs beforerunBypassso a malformed spec fails fast regardless of build reason. - Bypass — if
ADO_BUILD_REASONdoes not matchspec.context.build_reason(e.g. spec is forPullRequestbut the build isManual), auto-pass: emitSHOULD_RUN=true, tag the build, completeSucceeded, exit. - Fact acquisition — for every
FactSpecin the spec, either read a pipeline env var (isPipelineVarFact) or call the ADO REST API (pr_metadata,pr_labels,changed_files, …). Each per-fact failure is recorded in thePolicyTrackerand dispatched via that fact'sfailure_policy(fail_closed/fail_open/skip_dependents). - Predicate evaluation — for each
CheckSpec, thePolicyTrackerdecides whether the check isevaluate,pass,skip, orfailbased on which referenced facts are still available. Evaluator dispatches the predicate via theswitchinevaluatePredicate. Failing checks emitaddBuildTagand the overallSHOULD_RUNistrueiff every check ispassorskip.
If SHOULD_RUN ends up false, selfCancelIfRequested issues a
best-effort BuildStatus.Cancelling PATCH so the pipeline run is
visibly cancelled in the ADO UI rather than just paused on a gated
job.
The compiler injects these environment variables on the
bash: node gate/index.js step. gate.js reads them via
process.env:
| Env var | Source | Purpose |
|---|---|---|
GATE_SPEC |
compiled inline (base64) | The full GateSpec JSON |
SYSTEM_ACCESSTOKEN |
$(System.AccessToken) |
ADO REST auth |
ADO_COLLECTION_URI |
$(System.CollectionUri) |
ADO org base URL |
ADO_BUILD_REASON |
$(Build.Reason) |
Used by the bypass branch |
ADO_BUILD_ID |
$(Build.BuildId) |
Used for selfCancelIfRequested |
ADO_PROJECT / ADO_REPO_ID / ADO_PR_ID |
compiler-injected | PR-derived facts |
ADO_* (fact-specific) |
Fact::ado_exports() in Rust |
Per-fact pipeline-variable readers (e.g. ADO_PR_TITLE, ADO_SOURCE_BRANCH) |
ADO_API_TIMEOUT_MS |
optional override | Per-attempt timeout for every ADO REST call. Default 30 000. On timeout, the call is retried once; if the retry also times out, the gate falls back to the per-fact FailurePolicy. |
The exact contract for pipeline-variable facts (which env var maps to
which FactKind) lives in two places that must stay in lockstep:
- Rust:
Fact::ado_exports()insrc/compile/filter_ir.rs - TS:
ENV_BY_FACTplus theFactKindunion inscripts/ado-script/src/shared/env-facts.ts
The codegen drift check only mirrors the GateSpec shape, not the
env-var mapping, so when adding a new pipeline-variable fact you must
update both sides by hand. Fact::ado_exports() carries a docstring
pointing at the TS mirror as a reminder.
scripts/ado-script/
├── package.json # type:module; dep: azure-devops-node-api (lazy-imported)
├── tsconfig.json # strict; noUncheckedIndexedAccess; NodeNext
├── src/
│ ├── shared/ # Reusable across all bundles
│ │ ├── types.gen.ts # AUTO-GENERATED from GateSpec — do not edit
│ │ ├── types-prompt.gen.ts # AUTO-GENERATED from PromptSpec — do not edit
│ │ ├── auth.ts # WebApi factory; SDK is dynamic-imported here
│ │ ├── ado-client.ts # azure-devops-node-api wrapper + retry + timeout + pagination
│ │ ├── env-facts.ts # Pipeline-variable readers + ENV_BY_FACT + BRANCH_FACTS + ref-prefix stripping
│ │ ├── policy.ts # PolicyTracker state machine
│ │ └── vso-logger.ts # ##vso[…] emitters with property/message escaping; complete() is idempotent
│ ├── gate/ # gate.js entry point + per-concern modules
│ │ ├── index.ts # main(): decode → preflight → bypass → facts → eval → emit
│ │ ├── bypass.ts # build-reason auto-pass
│ │ ├── facts.ts # fact acquisition (env + REST)
│ │ ├── predicates.ts # 11 predicate evaluators + validatePredicateTree + glob ReDoS hardening
│ │ └── selfcancel.ts # best-effort build cancellation
│ └── prompt/ # prompt.js entry point + per-concern modules
│ ├── index.ts # main(): decode → strip FM → assemble → substitute → write
│ ├── frontmatter.ts # stripFrontMatter (mirrors parse_markdown_detailed in Rust)
│ └── substitute.ts # single-pass substitution engine (block-the-chain attack)
├── test/ # End-to-end smoke tests for built bundles
└── dist/<bundle>/index.js # ncc bundle output per bundle (gitignored)
The release workflow (.github/workflows/release.yml) runs
npm ci && npm run build, then flattens each dist/<bundle>/index.js
into a top-level <bundle>.js inside ado-script.zip (e.g. gate.js,
prompt.js). Pipelines download that asset at runtime by URL pinned to
the compiler's CARGO_PKG_VERSION, verify its SHA-256 against the
checksums.txt asset, then extract directly into /tmp/ado-aw-scripts/,
where each bundle is referenced by /tmp/ado-aw-scripts/<bundle>.js.
types.gen.ts is derived from the Rust IR via
schemars →
json-schema-to-typescript:
┌──────────────────────────┐ schemars ┌──────────────────────────┐
│ src/compile/filter_ir.rs │ ───────────► │ schema/gate-spec.schema │
│ #[derive(JsonSchema)] │ │ .json │
└──────────────────────────┘ └────────────┬─────────────┘
│ json2ts
▼
┌──────────────────────────────┐
│ src/shared/types.gen.ts │
│ (consumed by gate/*.ts) │
└──────────────────────────────┘
npm run codegen runs both schemas: codegen:gate regenerates
types.gen.ts from GateSpec, and codegen:prompt regenerates
types-prompt.gen.ts from PromptSpec. The CI workflow
(.github/workflows/ado-script.yml) regenerates both files and
runs git diff --exit-code to fail on drift, on both PRs and pushes
to main. If you change either IR shape in Rust, run
cd scripts/ado-script && npm run codegen and commit the regenerated
type files.
The Rust subcommands that emit the schemas are intentionally hidden:
cargo run -- export-gate-schema --output schema/gate-spec.schema.json
cargo run -- export-prompt-schema --output schema/prompt-spec.schema.jsonTriggerFiltersExtension
(src/compile/extensions/trigger_filters.rs) declares
needs_scripts_bundle() == true when any filters: block produces
checks. The compiler emits the shared install pair (NodeTool@0 +
checksum-verified ado-script.zip download) once per job:
- Setup job — the install pair is hoisted out of the extension via
compile/extensions/mod.rs::scripts_install_steps_if_needed. The trigger-filters extension then contributes only the gate step. - Agent job — the runtime prompt path (when
inlined-imports: false, the default) emits its own copy of the install pair viagenerate_prepare_agent_prompt. The Setup job's download is on a different ADO agent VM, so the Agent VM must re-download.
The wiring for trigger filters specifically:
NodeTool@0— installs Node 20.x LTS, capped attimeoutInMinutes: 5.curldownload + verify + extract — fetcheschecksums.txtandado-script.zipfrom thegithubnext/ado-awrelease matchingCARGO_PKG_VERSION, verifies the zip's SHA-256, thenunzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/. Also capped attimeoutInMinutes: 5.bash: node '/tmp/ado-aw-scripts/gate.js'— runs the gate withGATE_SPECand the env-var contract above.
The IR-to-bash codegen that produces step 3 is
compile_gate_step_external in src/compile/filter_ir.rs.
prompt.js is a single-shot Node program that runs in the Agent
job, before the agent is launched. It:
-
Decodes the base64-encoded
PromptSpecfromADO_AW_PROMPT_SPECand refuses to run on a mismatched schema version. -
Reads the agent
.mdsource from the workspace at the absolute path baked into the spec (already resolved from{{ trigger_repo_directory }}at compile time, so the spec carries a literal$(Build.SourcesDirectory)/path/to/agent.md). -
Strips the YAML front-matter block (mirroring
parse_markdown_detailedin Rust). -
Joins the body with any
PromptSpec.supplementscontributed by extensions, in the same ordergenerate_prepare_stepswould have emitted them ininlined-imports: truemode (Runtimes phase first, then Tools). -
Runs single-pass substitution over the joined content. The single regex pass recognises four token shapes, with replacement values returned verbatim and never re-scanned:
Token Resolved via Notes \$(VAR)/\$(VAR.SUB)escape Backslash stripped; $(VAR)stays literal.${{ parameters.NAME }}ADO_AW_PARAM_<NAME upper, hyphen→underscore>Only parameters listed in the spec substitute. $(VAR)/$(VAR.SUB)<NAME upper, dot→underscore>(process env)Unset vars left verbatim with a warning. $[ ... ]not substituted Left verbatim with one warning per render. Single-pass is load-bearing. It blocks the "queue-with-malicious-parameter-value" chaining attack: if a caller queues with
target = "$(System.AccessToken)", the substituted value lands in the rendered prompt as the literal string$(System.AccessToken)— not the access token itself. Same applies in reverse: a$(VAR)value containing${{ parameters.* }}is never re-expanded. -
Writes the rendered prompt to
/tmp/awf-tools/agent-prompt.mdfor the AWF sandbox.
Like gate.js, prompt.js is a data interpreter, not a code
evaluator — there is no eval, no Function, no vm. A compromised
compiler cannot use the spec to execute arbitrary code on the agent
runner.
Set inlined-imports: true in front matter to skip prompt.js
entirely and restore the legacy compile-time behaviour: the body is
embedded verbatim in a heredoc step at compile time, and extension
supplements are emitted as per-extension cat >> steps. Use this
when:
- The agent
.mdsource path will not be resolvable inside$(Build.SourcesDirectory)at runtime (e.g., compile happens outside the trigger repo). - The Agent pool cannot reach
github.comfor the release-asset download. - You need a fully self-contained compiled YAML for offline review or archival.
- Add a
Predicate+PredicateSpecvariant insrc/compile/filter_ir.rs. Runcargo testand update spec tests. - In
scripts/ado-script/, runnpm run codegensotypes.gen.tspicks up the new variant. - Add a
caseto theswitchinsrc/gate/predicates.ts::evaluatePredicate. - Add the new type name to
KNOWN_PREDICATE_TYPES(right above thevalidatePredicateTreefunction). Both updates are required — the drift testKNOWN_PREDICATE_TYPES stays in sync with evaluatePredicate switchinpredicates.test.tswill fail if you forget either. - Add a vitest case under
src/gate/__tests__/ports/<new-predicate>.test.ts.
- Add a
Factvariant insrc/compile/filter_ir.rsand updateFact::ado_exports(). (Its docstring reminds you about step 3.) npm run codegento regenerate types.- Add an entry to
ENV_BY_FACTand extend theFactKindunion inscripts/ado-script/src/shared/env-facts.ts. Without this step the gate silently treats the fact as missing. - If the fact value is ref-shaped (e.g. a branch name), add it to
the exported
BRANCH_FACTSset so the read-time strip is applied.
- Create
src/poll/index.tsand supporting modules underscripts/ado-script/src/poll/. Reuse anything insrc/shared/. - Add a build script to
package.json:and extend"build:poll": "ncc build src/poll/index.ts -o dist/poll -m -t"
buildto also run it. - Add vitest tests under
src/poll/__tests__/. - Wire from a new
CompilerExtension(or extend an existing one) that downloadsado-script.zip(already a release asset) and invokesnode /tmp/ado-aw-scripts/poll.jsas a runtime step. - Extend the release workflow's package step in
.github/workflows/release.yml— the flatten loop iterates over everydist/*/index.js, so a new bundle is picked up automatically as long as its build step writes todist/<name>/index.js.
From scripts/ado-script/:
npm ci # one-time
npm run codegen # regenerate types.gen.ts (compiles ado-aw first)
npm test # vitest unit tests
npm run typecheck # strict tsc --noEmit
npm run build # ncc-bundle each src/<bundle>/index.ts to dist/<bundle>/index.js
npm run test:smoke # build + smoke test the bundle end-to-endThe Rust-side E2E gate test compiles a real agent, extracts the
emitted GATE_SPEC, and shells out to the bundled gate.js:
cargo test --test gate_e2e -- --ignored --nocaptureEach bundled artifact must stay under 5 MB. The entry-point
chunk for gate.js is ~78 KB; the lazy-imported
azure-devops-node-api SDK lives in a separate ~2.7 MB chunk loaded
only when an ADO REST call is needed. Pipelines that bypass or rely
only on pipeline-variable facts never load the SDK.
If a future bundle blows the budget:
- First, check ncc's
--minifyand--targetflags. - If still too large, weigh dropping
azure-devops-node-apiin favor of hand-rolledfetchfor the hot endpoints. The retry / timeout / pagination helpers insrc/shared/ado-client.tsare written so they could wrap either approach.
- A user-facing
ado-script:front-matter block. Letting authors run arbitrary TypeScript at pipeline runtime would bypass the safe-output trust boundary and require sandboxing the project does not have. - Migrating the safe-output executors (
src/safeoutputs/*.rs) to Node. Stage 3 keeps a Rust-only execution path. - Migrating the agent-stats parser. It runs in-pipeline as part of Stage 1 wrap-up and has no TypeScript dependency need.
- Bundling Node itself. Pipelines install Node via
NodeTool@0.
filter-ir.md— the IR consumed bygate.js.extending.md— generic compiler-extension guide.