Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions .github/workflows/ado-script.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,22 @@ on:
paths:
- "scripts/ado-script/**"
- "src/compile/filter_ir.rs"
- "src/compile/prompt_ir.rs"
- "src/compile/extensions/trigger_filters.rs"
- "Cargo.toml"
- "Cargo.lock"
- ".github/workflows/ado-script.yml"
# Also run on pushes to main so any drift that slips through (e.g. a
# merge that bypassed PR CI, or a force-push) is caught loudly the
# moment it lands. If this fails on main, file a fix-drift issue and
# land a PR to regenerate `src/shared/types.gen.ts` and re-bundle —
# the workflow itself does not auto-PR.
# land a PR to regenerate the codegen outputs and re-bundle — the
# workflow itself does not auto-PR.
push:
branches: [main]
paths:
- "scripts/ado-script/**"
- "src/compile/filter_ir.rs"
- "src/compile/prompt_ir.rs"
- "src/compile/extensions/trigger_filters.rs"
- "Cargo.toml"
- "Cargo.lock"
Expand Down Expand Up @@ -54,10 +56,13 @@ jobs:

- name: Verify generated TypeScript is up to date
run: |
if ! git diff --exit-code -- scripts/ado-script/src/shared/types.gen.ts; then
if ! git diff --exit-code -- \
scripts/ado-script/src/shared/types.gen.ts \
scripts/ado-script/src/shared/types-prompt.gen.ts; then
echo ""
echo "::error::types.gen.ts is out of date with the Rust IR."
echo "::error::Generated TypeScript types are out of date with the Rust IR."
echo "Run 'cd scripts/ado-script && npm run codegen' and commit the result."
echo "This covers both types.gen.ts (gate spec) and types-prompt.gen.ts (prompt spec)."
exit 1
fi

Expand All @@ -69,11 +74,11 @@ jobs:
working-directory: scripts/ado-script
run: npm run typecheck

- name: Build bundle (gate.js)
- name: Build bundles (gate.js, prompt.js)
working-directory: scripts/ado-script
run: npm run build

- name: Smoke-test bundle
- name: Smoke-test bundles
working-directory: scripts/ado-script
run: npx vitest run -c vitest.config.smoke.ts

Expand Down
14 changes: 12 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,18 @@ jobs:
- name: Package ado-script bundle
run: |
set -euo pipefail
cd scripts
zip -r ../ado-script.zip ado-script/dist
# Flatten the ncc per-bundle layout (`dist/<name>/index.js`) into
# top-level `<name>.js` files in the zip. Pipelines extract the
# zip to `/tmp/ado-aw-scripts/` and reference each helper by name
# (e.g. `/tmp/ado-aw-scripts/gate.js`), so internal `dist/<name>/`
# nesting does not need to leak into the runtime path.
staging="$(mktemp -d)"
for bundle in scripts/ado-script/dist/*/index.js; do
name="$(basename "$(dirname "$bundle")")"
cp "$bundle" "$staging/$name.js"
done
(cd "$staging" && zip "$GITHUB_WORKSPACE/ado-script.zip" *.js)
unzip -l ado-script.zip

- name: Upload release assets
env:
Expand Down
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Every compiled pipeline runs as three sequential jobs:
│ │ ├── gitattributes.rs # .gitattributes management for compiled pipelines
│ │ ├── filter_ir.rs # Filter expression IR: Fact/Predicate types, lowering, validation, codegen
│ │ ├── pr_filters.rs # PR trigger filter generation (native ADO + gate steps)
│ │ ├── prompt_ir.rs # Prompt spec IR: PromptSpec/PromptSupplement, schemars schema for prompt.js
│ │ ├── extensions/ # CompilerExtension trait and infrastructure extensions
│ │ │ ├── mod.rs # Trait, Extension enum, collect_extensions(), re-exports
│ │ │ ├── github.rs # Always-on GitHub MCP extension
Expand Down Expand Up @@ -156,8 +157,9 @@ Every compiled pipeline runs as three sequential jobs:
│ ├── update-ado-agentic-workflow.md # Guide for modifying an existing agentic pipeline
│ └── debug-ado-agentic-workflow.md # Guide for troubleshooting a failing agentic pipeline
├── scripts/ # Supporting scripts shipped as release artifacts
│ ├── ado-script/ # TypeScript workspace for bundled gate.js (and future bundles)
│ └── gate.js # Bundled gate evaluator (built from scripts/ado-script/, see docs/ado-script.md)
│ ├── ado-script/ # TypeScript workspace for bundled gate.js, prompt.js, and future bundles
│ ├── gate.js # Bundled gate evaluator (Setup-job step; see docs/ado-script.md)
│ └── prompt.js # Bundled prompt renderer (Agent-job step; see docs/ado-script.md)
├── tests/ # Integration tests and fixtures
├── docs/ # Per-concept reference documentation (see index below)
├── Cargo.toml # Rust dependencies
Expand Down Expand Up @@ -237,8 +239,9 @@ index to jump to the right page.
rewrite on breaking-change updates, contributor workflow for
adding codemods.
- [`docs/ado-script.md`](docs/ado-script.md) — `ado-script` workspace
(`scripts/ado-script/`): the bundled TypeScript runtime helpers (today:
`gate.js`), schemars-driven type codegen, and the A2 design decision.
(`scripts/ado-script/`): the bundled TypeScript runtime helpers
(`gate.js` for trigger gates, `prompt.js` for runtime prompt
rendering), schemars-driven type codegen, and the A2 design decision.
- [`docs/local-development.md`](docs/local-development.md) — local development
setup notes.

Expand Down
157 changes: 126 additions & 31 deletions docs/ado-script.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@
`ado-script` is the umbrella name for the TypeScript workspace at
[`scripts/ado-script/`](../scripts/ado-script/). It produces small,
ncc-bundled Node programs that the **compiler injects into every emitted
pipeline** as runtime helpers. The first (and currently only) bundle is
`gate.js`, the trigger-filter gate evaluator.
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 `.md` from the workspace at runtime, strips its front matter,
runs single-pass variable substitution, and writes the rendered
prompt for the AWF sandbox. See *What `prompt.js` does* below.

> **Internal-only.** `ado-script` is not a user-facing front-matter
> feature. Authors never write an `ado-script:` block in their agent
> markdown. The compiler decides when an `ado-script` bundle is needed
> and how to wire it. See [`docs/tools.md`](tools.md) for what *is*
> user-facing.
> user-facing. The one user-visible knob is
> [`inlined-imports: true`](front-matter.md) which opts back into the
> legacy compile-time prompt-embedding behaviour and skips
> `prompt.js`.

## What `gate.js` does

Expand Down Expand Up @@ -141,27 +149,34 @@ scripts/ado-script/
├── tsconfig.json # strict; noUncheckedIndexedAccess; NodeNext
├── src/
│ ├── shared/ # Reusable across all bundles
│ │ ├── types.gen.ts # AUTO-GENERATED from Rust IR — do not edit
│ │ ├── 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
├── test/ # End-to-end smoke tests
└── dist/gate/index.js # ncc bundle output (gitignored)
│ ├── 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 zips `scripts/ado-script/dist/` into
the `ado-script.zip` release asset. 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.
`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`.

## Schema codegen

Expand All @@ -182,24 +197,40 @@ its SHA-256 against the `checksums.txt` asset, then extract.
└──────────────────────────────┘
```

`npm run codegen` runs both stages. The CI workflow
(`.github/workflows/ado-script.yml`) regenerates the file and runs
`git diff --exit-code` to fail on drift, on both PRs and pushes to
`main`. If you change the IR shape in Rust, run
`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
`types.gen.ts`.
type files.

The Rust subcommand that emits the schema is intentionally hidden:
The Rust subcommands that emit the schemas are intentionally hidden:

```sh
cargo run -- export-gate-schema --output schema/gate-spec.schema.json
cargo run -- export-gate-schema --output schema/gate-spec.schema.json
cargo run -- export-prompt-schema --output schema/prompt-spec.schema.json
```

## How the gate bundle is wired into emitted pipelines

`TriggerFiltersExtension`
(`src/compile/extensions/trigger_filters.rs`) injects three Setup-job
steps when any `filters:` block is active:
(`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 via `generate_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:

1. **`NodeTool@0`** — installs Node 20.x LTS, capped at
`timeoutInMinutes: 5`.
Expand All @@ -208,12 +239,74 @@ steps when any `filters:` block is active:
`CARGO_PKG_VERSION`, verifies the zip's SHA-256, then
`unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/`.
Also capped at `timeoutInMinutes: 5`.
3. **`bash: node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'`** —
3. **`bash: node '/tmp/ado-aw-scripts/gate.js'`** —
runs the gate with `GATE_SPEC` and the env-var contract above.

The IR-to-bash codegen that produces these steps is
The IR-to-bash codegen that produces step 3 is
`compile_gate_step_external` in `src/compile/filter_ir.rs`.

## What `prompt.js` does

`prompt.js` is a single-shot Node program that runs in the **Agent
job**, before the agent is launched. It:

1. Decodes the base64-encoded [`PromptSpec`](../src/compile/prompt_ir.rs)
from `ADO_AW_PROMPT_SPEC` and refuses to run on a mismatched
schema version.
2. Reads the agent `.md` source 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`).
3. Strips the YAML front-matter block (mirroring
`parse_markdown_detailed` in Rust).
4. Joins the body with any `PromptSpec.supplements` contributed by
extensions, in the same order
[`generate_prepare_steps`](../src/compile/common.rs) would have
emitted them in `inlined-imports: true` mode (Runtimes phase first,
then Tools).
5. 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.

6. Writes the rendered prompt to `/tmp/awf-tools/agent-prompt.md` for
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.

### Opt-out: `inlined-imports: true`

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 `.md` source path will not be resolvable inside
`$(Build.SourcesDirectory)` at runtime (e.g., compile happens
outside the trigger repo).
- The Agent pool cannot reach `github.com` for the release-asset
download.
- You need a fully self-contained compiled YAML for offline review or
archival.

## Modifying `ado-script`

### Add a new predicate
Expand Down Expand Up @@ -255,10 +348,12 @@ The IR-to-bash codegen that produces these steps is
3. Add vitest tests under `src/poll/__tests__/`.
4. Wire from a new `CompilerExtension` (or extend an existing one)
that downloads `ado-script.zip` (already a release asset) and
invokes `node /tmp/ado-aw-scripts/ado-script/dist/poll/index.js`
invokes `node /tmp/ado-aw-scripts/poll.js`
as a runtime step.
5. No release-workflow change is needed — `zip -r ado-script/dist`
picks up the new bundle automatically.
5. Extend the release workflow's package step in
`.github/workflows/release.yml` — the flatten loop iterates over
every `dist/*/index.js`, so a new bundle is picked up automatically
as long as its build step writes to `dist/<name>/index.js`.

### Local development loop

Expand All @@ -269,7 +364,7 @@ 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 to dist/gate/index.js
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-end
```

Expand Down
19 changes: 19 additions & 0 deletions docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub trait CompilerExtension {
fn required_awf_mounts(&self) -> Vec<AwfMount>; // AWF Docker volume mounts
fn awf_path_prepends(&self) -> Vec<String>; // Directories to add to chroot PATH
fn agent_env_vars(&self) -> Vec<(String, String)>; // Agent env vars (e.g., PIP_INDEX_URL)
fn needs_scripts_bundle(&self) -> bool; // Declare dependency on ado-script.zip
}
```

Expand All @@ -62,6 +63,24 @@ gates or checks that must complete before the agent is launched.
This guarantees runtime install steps run before tool steps that may depend
on them.

**`prompt_supplement()` delivery**: By default (`inlined-imports: false`),
supplement strings travel via [`PromptSpec.supplements`](../src/compile/prompt_ir.rs)
and are appended and substituted by `prompt.js` at pipeline runtime — they
do **not** appear as `cat >>` steps in `prepare_steps`. The substitution
applies the same `${{ parameters.* }}` / `$(VAR)` rules to supplement
content as to the body, so an extension can parameterize its supplement
the same way an author parameterizes the body. Only when
`inlined-imports: true` does the compiler wrap each supplement in a
heredoc `cat >>` step via `wrap_prompt_append`.

**`needs_scripts_bundle()`**: Return `true` if the extension's emitted
steps invoke a bundled `ado-script.zip` helper (e.g., `gate.js`,
`prompt.js`). The compiler then emits the shared NodeTool@0 +
checksum-verified download pair **once** in the consuming job; the
extension itself emits only its `node /tmp/ado-aw-scripts/<bundle>.js`
invocation. See `TriggerFiltersExtension` for the reference
implementation.

To add a new runtime or tool: (1) create a directory under `src/tools/` or `src/runtimes/`, (2) implement `CompilerExtension` in `extension.rs`, (3) add a variant to the `Extension` enum and a collection check in `collect_extensions()` in `src/compile/extensions/mod.rs`.

### Filter IR (`src/compile/filter_ir.rs`)
Expand Down
Loading
Loading