From 7aec5f18ddd4e20406f02268520aeba451323922 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 14 Jul 2026 07:57:09 +0100 Subject: [PATCH 01/12] feat(compile): reusable cross-repository custom safe-output components Add support for importing typed, secret-bearing custom safe-output tools from shared (optionally cross-repository, SHA-pinned) components, per #1473. - imports: front matter + resolver with a committed SHA-keyed manifest cache, import-schema validation/substitution, and a consumer-wins merge pass - repository-resource endpoint for GitHub / GitHub Enterprise sources, with compiler-synthesized import repo aliases and strict validation - config-driven dynamic MCP tools with compile-time closed, scalar-only input schemas generated from safe-outputs.scripts / safe-outputs.jobs - one dedicated, Detection-gated executor job per custom definition, with the executor CLI modes `execute --custom-config` (scripts-style native dispatch) and `execute --custom-phase pre|post` (jobs-style wrapper); reviewed tools route through ManualReview - versioned CustomExecutionRecord + audit component-provenance with a local marker cross-check - docs (docs/imports.md and updates) and a four-target acceptance matrix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f --- AGENTS.md | 19 +- docs/front-matter.md | 61 + docs/imports.md | 244 ++++ docs/network.md | 48 + docs/safe-outputs.md | 139 ++ src/audit/analyzers/safe_outputs.rs | 265 +++- src/audit/findings.rs | 42 +- src/audit/model.rs | 59 + src/audit/render/console.rs | 2 + src/audit/render/json.rs | 2 + src/compile/agentic_pipeline.rs | 991 ++++++++++++- src/compile/common.rs | 351 ++++- src/compile/custom_tools.rs | 437 ++++++ src/compile/extensions/ado_aw_marker.rs | 146 +- src/compile/extensions/mod.rs | 6 +- src/compile/imports/alias.rs | 381 +++++ src/compile/imports/integration_tests.rs | 353 +++++ src/compile/imports/merge.rs | 469 ++++++ src/compile/imports/mod.rs | 621 ++++++++ src/compile/imports/schema.rs | 721 ++++++++++ src/compile/ir/lower.rs | 36 +- src/compile/ir/mod.rs | 1 + src/compile/mod.rs | 33 +- src/compile/onees_ir.rs | 1 + src/compile/types.rs | 459 +++++- src/execute.rs | 1341 +++++++++++++++++- src/main.rs | 120 +- src/mcp.rs | 170 ++- src/mcp_custom_tools.rs | 251 ++++ tests/compiler_tests.rs | 260 +++- tests/fixtures/custom-safe-output-scripts.md | 23 + 31 files changed, 7814 insertions(+), 238 deletions(-) create mode 100644 docs/imports.md create mode 100644 src/compile/custom_tools.rs create mode 100644 src/compile/imports/alias.rs create mode 100644 src/compile/imports/integration_tests.rs create mode 100644 src/compile/imports/merge.rs create mode 100644 src/compile/imports/mod.rs create mode 100644 src/compile/imports/schema.rs create mode 100644 src/mcp_custom_tools.rs create mode 100644 tests/fixtures/custom-safe-output-scripts.md diff --git a/AGENTS.md b/AGENTS.md index 41667fac..5231b2f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ ├── mod.rs # Module entry point and Compiler trait │ │ ├── common.rs # Shared helpers across targets │ │ ├── ado_bundle.rs # Registry of ado-script bundles and their compile-time env contracts: Bundle enum (path + auth), apply_bundle_auth() (single chokepoint projecting SYSTEM_ACCESSTOKEN into every REST-calling bundle step), token_source_for() (System.AccessToken vs SC_WRITE_TOKEN selection), is_redundant_ado_mirror() (identifies auto-injected ADO predefined var re-projections) -│ │ ├── agentic_pipeline.rs # Canonical Setup → Agent → Detection → (ManualReview?) → SafeOutputs(+SafeOutputs_Reviewed?) → Teardown → Conclusion shape (Conclusion emitted when configured; shared by every target); BuiltPipelineContext, build_pipeline_context, build_canonical_jobs, per-job builders incl. build_manual_review_job + SafeOutputsVariant split, fold_agent_conditions, agent_job_variables_hoist +│ │ ├── agentic_pipeline.rs # Canonical Setup → Agent → Detection → (ManualReview?) → Custom_* → SafeOutputs(+SafeOutputs_Reviewed?) → Teardown → Conclusion shape (Conclusion emitted when configured; shared by every target); BuiltPipelineContext, build_pipeline_context, build_canonical_jobs, per-job builders incl. build_manual_review_job + custom safe-output jobs + SafeOutputsVariant split, fold_agent_conditions, agent_job_variables_hoist │ │ ├── standalone.rs # Standalone pipeline compiler │ │ ├── standalone_ir.rs # Standalone target typed-IR builder │ │ ├── onees.rs # 1ES Pipeline Template compiler @@ -74,6 +74,8 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ ├── filter_ir.rs # Filter expression IR: Fact/Predicate types, lowering, validation, codegen │ │ ├── pr_filters.rs # PR trigger filter generation (native ADO + gate steps) │ │ ├── path_layout_check.rs # Warning-only checkout-aware path validation: $(Build.SourcesDirectory)/ refs in steps, runtime-import targets, deprecated directory markers in the body +│ │ ├── custom_tools.rs # Compile-time custom safe-output MCP schema generation for safe-outputs.scripts/jobs (closed scalar input schemas, custom-tools JSON) +│ │ ├── imports/ # Reusable component imports: mod.rs resolver + SHA-keyed .ado-aw/imports cache, schema.rs import-schema/with validation + substitution, alias.rs generated repo aliases + template diagnostics, merge.rs consumer-wins front-matter/body merge │ │ ├── extensions/ # CompilerExtension trait and infrastructure extensions │ │ │ ├── mod.rs # Trait, Extension enum, collect_extensions(), re-exports │ │ │ ├── ado_aw_marker.rs # Always-on metadata marker extension (emits # ado-aw-metadata JSON) @@ -118,10 +120,11 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ ├── emit.rs # Thin `lower() + serde_yaml::to_string()` wrapper │ │ └── summary.rs # Public, serializable PipelineSummary / GraphSummary for agent-facing tooling (see docs/ir.md Public JSON summary) │ ├── init.rs # Repository initialization for AI-first authoring: scaffolds a dispatcher agent (.github/agents/ado-aw.agent.md) AND skill (.github/skills/ado-aw/SKILL.md); `--agency` plugin scaffold embeds agency/plugins/ado-aw/ via include_str! -│ ├── execute.rs # Stage 3 safe output execution +│ ├── execute.rs # Stage 3 safe output execution, including custom safe-output modes: scripts-style `--custom-config` native dispatch and jobs-style `--custom-phase pre|post` wrapper/result validation │ ├── fuzzy_schedule.rs # Fuzzy schedule parsing │ ├── logging.rs # File-based logging infrastructure │ ├── mcp.rs # SafeOutputs MCP server (stdio + HTTP) +│ ├── mcp_custom_tools.rs # Dynamic SafeOutputs MCP tool registration from compiler-generated custom-tools JSON │ ├── mcp_author/ # Author-facing read-only MCP server for local IDE/Copilot Chat integrations │ │ ├── mod.rs # Tool router + handlers for inspect/graph/deps/outputs/whatif/lint/catalog/trace/audit │ │ └── tests.rs # MCP-author integration / contract tests @@ -305,6 +308,10 @@ index to jump to the right page. - [`docs/front-matter.md`](docs/front-matter.md) — full agent file format (markdown body + YAML front matter grammar) with every supported field. +- [`docs/imports.md`](docs/imports.md) — reusable local and SHA-pinned + cross-repository markdown components: `imports:`, `import-schema:`, committed + `.ado-aw/imports/` cache, merge semantics, and custom safe-output component + examples. - [`docs/runtime-imports.md`](docs/runtime-imports.md) — runtime prompt import markers, path resolution, and `inlined-imports:` behavior. - [`docs/schedule-syntax.md`](docs/schedule-syntax.md) — fuzzy schedule time @@ -329,7 +336,8 @@ index to jump to the right page. configured via the `execution-context:` front-matter block. - [`docs/safe-outputs.md`](docs/safe-outputs.md) — full reference for every safe-output tool agents can use to propose actions (PRs, work items, wiki - pages, comments, etc.) plus their per-agent configuration. + pages, comments, etc.), custom `safe-outputs.scripts` / `safe-outputs.jobs` + components, and per-agent configuration. - [`docs/safe-output-permissions.md`](docs/safe-output-permissions.md) — diagnosis and fix reference for Stage 3 401/403 failures: the default build identity (PCBS vs project-scoped Build Service), @@ -369,8 +377,9 @@ index to jump to the right page. - [`docs/mcpg.md`](docs/mcpg.md) — MCP Gateway architecture and pipeline integration. - [`docs/network.md`](docs/network.md) — AWF network isolation, default - allowed domains, ecosystem identifiers, blocking, and ADO `permissions:` - service-connection model. + allowed domains, ecosystem identifiers, blocking, repository-resource + `endpoint:` service connections, and ADO `permissions:` service-connection + model. - [`docs/extending.md`](docs/extending.md) — adding new CLI commands, compile targets, front-matter fields, typed IR extensions, safe-output tools, first-class tools, and runtimes; the `CompilerExtension` trait. diff --git a/docs/front-matter.md b/docs/front-matter.md index b6ca4cff..a57670df 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -39,8 +39,16 @@ repos: # compact repository declarations (replaces rep - my-org/my-repo # shorthand: alias="my-repo", type=git, ref=refs/heads/main, checkout=true - reponame=my-org/another-repo # shorthand with explicit alias - name: my-org/templates # object form for full control + type: github # external repo resource type; default is git ref: refs/heads/release/2.x checkout: false # declared as resource only, not checked out by the agent + endpoint: github-templates # required for type: github/githubenterprise/bitbucket +imports: # reusable markdown components; see docs/imports.md + - ./components/local-guidance.md + - uses: octo/shared/components/notify.md@0123456789abcdef0123456789abcdef01234567 + endpoint: github-shared-components + with: # non-secret import-schema inputs + channel: service-alerts tools: # optional tool configuration bash: ["cat", "ls", "grep"] # explicit bash allow-list; when omitted, all bash tools are allowed (unrestricted) edit: true # enable file editing tool (default: true) @@ -242,6 +250,58 @@ runtime — write it as clear, structured natural-language instructions. > report on pipeline failures and surfaces diagnostic signals. See > [`docs/conclusion.md`](conclusion.md). +## Reusable Imports (`imports:` / `import-schema:`) + +`imports:` lets a workflow reuse local or SHA-pinned cross-repository markdown +components. Each imported file is parsed as regular ado-aw markdown with YAML +front matter; the compiler validates optional `import-schema:` inputs, applies +`${{ ado.aw.import-inputs. }}` substitutions, then merges the imported +front matter and body into the consumer workflow. + +```yaml +imports: + - ./components/local-policy.md + - octo/shared-agents/components/notify.md@0123456789abcdef0123456789abcdef01234567 + - uses: octo/shared-agents/components/deploy.md@89abcdef0123456789abcdef0123456789abcdef + endpoint: github-shared-components + with: + environment: prod + region: westus3 +``` + +Object-form fields: + +| Field | Description | +|-------|-------------| +| `uses` | Import spec. Local paths are relative to the importing `.md` file. Cross-repo specs use `owner/repo/path@<40-character-sha>`; branches/tags are rejected. | +| `with` | Non-secret values validated against the imported file's `import-schema:`. | +| `endpoint` | Azure DevOps service connection for GitHub/GitHub Enterprise runtime repository resources created for imported component sources. | + +Import specs may also include `#Section` to import only a markdown heading +section, and a trailing `?` to make the import optional. + +Reusable components declare compile-time inputs with `import-schema:`: + +```yaml +import-schema: + channel: + type: string + required: true + severity: + type: choice + options: [info, warning, critical] + default: info + labels: + type: array + items: + type: string +``` + +Supported types are `string`, `number`, `boolean`, `choice`, `array`, and +`object` (object properties are currently one level deep). See +[`imports.md`](imports.md) for the full syntax, cache layout, merge semantics, +limitations, and custom safe-output component examples. + ## Inline step validation (`setup` / `steps` / `post-steps` / `teardown`) Inline steps are authored as raw Azure DevOps YAML and are emitted into the @@ -402,6 +462,7 @@ Object fields: | `alias` | last segment of `name` | Repository alias (maps to ADO `repository:`) | | `type` | `git` | ADO repository resource type | | `ref` | `refs/heads/main` | Branch or tag reference | +| `endpoint` | *(none)* | Azure DevOps service connection. Required for `type: github`, `githubenterprise`, or `bitbucket`; not needed for same-org Azure Repos `git`. | | `checkout` | `true` | Whether the agent job clones this repo | | `fetch-depth` | *(ADO default)* | Shallow-clone depth for this repo's checkout (ADO `fetchDepth`). `0` = full history | | `fetch-tags` | *(ADO default)* | Whether to fetch git tags during checkout (ADO `fetchTags`) | diff --git a/docs/imports.md b/docs/imports.md new file mode 100644 index 00000000..9308515b --- /dev/null +++ b/docs/imports.md @@ -0,0 +1,244 @@ +# Reusable Imports + +_Part of the [ado-aw documentation](../AGENTS.md)._ + +`imports:` lets one agent file reuse another markdown component, including +cross-repository components pinned to an immutable commit SHA. The imported file +is the same markdown + YAML-front-matter format as a normal workflow: its front +matter is merged into the consumer, and its markdown body is prepended to the +consumer's prompt. + +This is separate from [`{{#runtime-import}}`](runtime-imports.md). Runtime +imports expand prompt snippets on the pipeline runner; `imports:` is resolved by +`ado-aw compile`, validates optional `import-schema:` inputs, and can contribute +front-matter configuration such as tools, runtimes, MCP servers, and custom safe +outputs. + +## Syntax + +`imports:` is a flat list. Each entry is either a bare spec string or an object +with `uses`, optional `with`, and optional `endpoint`: + +```yaml +imports: + - ./components/local-guidance.md + - octo/shared-agents/components/notify.md@0123456789abcdef0123456789abcdef01234567 + - uses: octo/shared-agents/components/deploy.md@89abcdef0123456789abcdef0123456789abcdef + endpoint: github-shared-components + with: + environment: prod + region: westus3 +``` + +### Import specs + +| Form | Meaning | +|------|---------| +| `path/to/component.md` | Local import, resolved relative to the importing `.md` file. | +| `owner/repo/path/to/component.md@` | Cross-repository import. `` must be a full 40-character commit SHA; branches and tags are rejected. | +| `...#Section` | Import only a `# Section` or `## Section` from the markdown body. | +| `...?` | Optional import. If the target is missing, it is skipped. | + +The optional marker is trailing, so a sectioned optional import looks like +`owner/repo/component.md@0123...cdef#Usage?`. + +`endpoint:` names the Azure DevOps service connection used by the generated +runtime repository resource for GitHub/GitHub Enterprise component sources. It +is not used for the compile-time manifest fetch. Azure Repos (`type: git`) +checkouts do not need an endpoint; GitHub, GitHub Enterprise, and Bitbucket +repository resources do. See [Repository resource endpoints](network.md#repository-resource-endpoints). + +## Cross-repository resolution and cache + +Cross-repository imports are immutable: the spec must include a full commit SHA. +At compile time, ado-aw fetches the imported **markdown manifest** and stores a +SHA-keyed copy under: + +```text +.ado-aw/imports////.md +``` + +The cache is intended to be committed. ado-aw also creates +`.ado-aw/imports/.gitattributes` marking cached imports as generated and using +`merge=ours`, mirroring gh-aw's committed import-cache model. + +Only the markdown manifest is cached. Script files and other executor source are +not vendored into `.ado-aw/imports/`; script-bearing custom safe-output +components are checked out in their dedicated executor job and verified at the +pinned SHA before their code runs. + +Current MVP notes: + +- The remote manifest fetcher uses the GitHub Contents API via `gh api` with the + compiler host's GitHub auth. Azure Repos manifest fetching is a follow-up. +- Nested/transitive import resolution is not expanded yet; the current resolver + processes the workflow's top-level `imports:` list. +- A workflow may declare at most 20 imports, and each resolved manifest is + capped at 256 KiB. + +## `import-schema:` and `with:` + +A reusable component can declare non-secret inputs with `import-schema:`. +Consumers pass values through `with:`. Values are validated at compile time, +defaults are applied, and placeholders of the form +`${{ ado.aw.import-inputs. }}` are substituted throughout the imported front +matter and body before merge. + +Supported schema types are `string`, `number`, `boolean`, `choice`, `array`, and +`object`. `choice` uses an `options:` list. `array` uses an `items:` schema. +`object` uses `properties:`; object properties are currently one level deep. +Unknown `with:` keys, missing required inputs, and values of the wrong type are +compile-time errors. + +```markdown +--- +import-schema: + channel: + type: string + required: true + description: Notification channel name. + severity: + type: choice + options: [info, warning, critical] + default: info + labels: + type: array + items: + type: string + delivery: + type: object + properties: + retries: + type: number + default: 2 +safe-outputs: + scripts: + notify-team: + description: Send a team notification. + max: 3 + run: node tools/notify.js + inputs: + title: + type: string + required: true + max-length: 120 + body: + type: string + required: true + env: + NOTIFY_TOKEN: TEAM_NOTIFY_TOKEN +--- +When notifying the team, use channel `${{ ado.aw.import-inputs.channel }}` and +severity `${{ ado.aw.import-inputs.severity }}`. +``` + +Consumer: + +```yaml +imports: + - uses: octo/shared-agents/components/notify.md@0123456789abcdef0123456789abcdef01234567 + endpoint: github-shared-components + with: + channel: service-alerts + severity: warning + labels: [agentic, automated] + delivery: + retries: 3 +safe-outputs: + notify-team: + require-approval: true +``` + +`with:` values are not secrets. Pass secrets through custom safe-output `env:` +bindings, which name Azure DevOps variables and are scoped to the privileged +custom executor job. + +## Merge semantics + +Imports are merged in declaration order, then the consumer workflow is overlaid +on top. Precedence is: + +```text +consumer workflow > later import > earlier import +``` + +- Scalar/singleton fields use the highest-precedence explicit value. +- Mapping collections (`tools`, `mcp-servers`, `safe-outputs`, `runtimes`, + `env`) merge additively by key. Duplicate keys from two different imports are + hard errors. +- Sequence fields (`parameters`, `repos`, `variable-groups`) concatenate in + import order, then consumer entries. +- The consumer may configure an imported safe-output tool, for example by adding + `require-approval`, but may not replace executor-defining fields such as + `steps`, `env`, `inputs`, `run`, or `entrypoint`. +- Imported markdown bodies are concatenated in declaration order, followed by + the consumer body. +- `import-schema:` and `imports:` are consumed by the merge and do not appear in + the merged workflow. + +## Example: shared custom safe-output job + +Shared component manifest: + +```markdown +--- +import-schema: + service: + type: string + required: true +safe-outputs: + jobs: + create-service-ticket: + description: Create an incident ticket in the service desk. + max: 2 + inputs: + title: + type: string + required: true + max-length: 160 + priority: + type: choice + options: [low, normal, high] + required: true + env: + SERVICE_DESK_TOKEN: SERVICE_DESK_TOKEN + SERVICE_NAME: SERVICE_NAME + steps: + - bash: | + set -euo pipefail + : > "$ADO_AW_SAFE_OUTPUT_RESULTS" + while IFS= read -r proposal; do + proposal_id=$(echo "$proposal" | jq -r '.proposal_id') + title=$(echo "$proposal" | jq -r '.title') + # Call your service-desk client here, honoring staged mode. + jq -cn \ + --arg proposal_id "$proposal_id" \ + --arg title "$title" \ + '{schema_version:1, proposal_id:$proposal_id, status:"success", message:("created ticket for " + $title)}' \ + >> "$ADO_AW_SAFE_OUTPUT_RESULTS" + done < "$ADO_AW_SAFE_OUTPUT_PROPOSALS" + displayName: Create service ticket +--- +Use `create-service-ticket` only when a durable service-desk record is needed +for `${{ ado.aw.import-inputs.service }}`. +``` + +Consumer workflow: + +```yaml +imports: + - uses: contoso/ado-aw-components/service-ticket.md@89abcdef0123456789abcdef0123456789abcdef + endpoint: github-components + with: + service: payments-api +safe-outputs: + create-service-ticket: + require-approval: + approvers: ["[Contoso]\\SRE Leads"] + instructions: Confirm the ticket title and priority before approving. +``` + +The imported tool appears to the agent as a typed SafeOutputs MCP tool. Agent +proposals still flow through Detection and optional manual review before the +isolated `Custom_create_service_ticket` executor job receives the secret env +bindings and performs the side effect. diff --git a/docs/network.md b/docs/network.md index cd53174a..2f3bbf6b 100644 --- a/docs/network.md +++ b/docs/network.md @@ -189,6 +189,54 @@ network: - "*.github.com" # Remove wildcard variant too ``` +## Repository resource endpoints + +Azure DevOps repository resources backed by an external service connection must +declare `endpoint:`. ado-aw validates this at compile time so the generated YAML +does not fail later in Azure Pipelines. + +| Repository `type` | `endpoint:` required? | Notes | +|-------------------|-----------------------|-------| +| `git` | No | Same-organization Azure Repos checkout using the build's OAuth token. | +| `github` | Yes | Azure DevOps GitHub service connection. | +| `githubenterprise` | Yes | Azure DevOps GitHub Enterprise service connection. | +| `bitbucket` | Yes | Azure DevOps Bitbucket service connection. | + +```yaml +repos: + - name: octo/shared-components + alias: shared-components + type: github + endpoint: github-shared-components + ref: refs/heads/main + checkout: false +``` + +The same rule applies to repository resources generated for reusable +[`imports:`](imports.md): object-form imports can specify the ADO service +connection with `endpoint:`: + +```yaml +imports: + - uses: octo/shared-components/components/notify.md@0123456789abcdef0123456789abcdef01234567 + endpoint: github-shared-components +``` + +`endpoint:` is an Azure DevOps runtime authorization setting. It is not passed +to the agent, Detection, or the compile-time manifest fetcher. + +### Template targets (`target: job` / `target: stage`) + +`target: job` and `target: stage` emit Azure DevOps templates, and templates +cannot declare top-level `resources.repositories`. For imports that require a +generated repository resource, ado-aw emits a diagnostic naming the generated +alias (for example `import_octo_shared_components_`). The parent pipeline +that includes the template must declare and authorize that repository resource +with the same alias and endpoint. + +Standalone and 1ES targets own their top-level resources, so ado-aw emits the +repository resource directly. + ## Permissions (ADO Access Tokens) ADO does not support fine-grained permissions — there are two access levels: diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index 7d338e54..f01b5d17 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -175,6 +175,145 @@ ARM-minted token, e.g. for cross-org writes or named-identity attribution. See [`docs/network.md`](network.md) and [`docs/ir.md`](ir.md) for the typed SafeOutputs job wiring. +## Custom safe outputs (scripts & jobs) + +Reusable components imported with [`imports:`](imports.md) can add custom +agent-callable safe-output tools under `safe-outputs.scripts.` or +`safe-outputs.jobs.`. Both mechanisms preserve the standard trust +boundary: + +```text +Agent MCP proposal → Detection → optional ManualReview → Custom_ executor job +``` + +The Agent and Detection jobs see only the generated closed MCP schema and the +proposal artifact. Secret variables and service credentials named by the custom +tool are bound only in the dedicated `Custom_` executor job. + +### Shared fields + +Both custom forms accept: + +| Field | Description | +|-------|-------------| +| `description` | Human-readable MCP tool description shown to the agent. | +| `inputs` | Agent-facing typed input schema. MVP types are scalar only: `string`, `number`, `boolean`, and `choice`. | +| `max` | Maximum proposals of this custom tool to attempt in one run. Omitted tools default to 3. | +| `env` | Mapping of environment variable names to Azure DevOps variable names. Values are treated as secret bindings and scoped to the custom executor job. | + +The generated MCP schema is closed (`additionalProperties: false`), so extra +agent-supplied keys are rejected. String inputs default to `maxLength: 4000`; +set `max-length` per field to choose a smaller bound (up to the compiler's hard +limit of 8000). `choice` inputs require an `options:` list. + +Custom tool names must be safe tool identifiers and cannot collide with built-in +safe-output names. `require-approval` works for custom tools exactly like it +does for built-ins: configure it under the tool's top-level name in the consumer +workflow, or set a section-level default. + +```yaml +safe-outputs: + notify-team: + require-approval: true +``` + +### `safe-outputs.scripts.` + +Scripts-style tools declare an entrypoint command with `run:` (or +`entrypoint:`). The compiled job writes a compiler-generated custom config and +runs one `ado-aw execute --custom-config ` step. The executor owns the +proposal loop: for each selected proposal it invokes the entrypoint, passes the +proposal JSON on stdin and in `AW_PROPOSAL`, enforces the budget/staged mode, +sanitizes the result, and appends the final execution record. + +The script must print exactly one JSON line to stdout: + +```json +{"status":"success","message":"sent notification","data":{"id":"123"}} +``` + +`status` may be `success`/`succeeded`, `failure`/`failed`, or `staged`. + +```yaml +safe-outputs: + scripts: + notify-team: + description: Send a structured team notification. + max: 3 + run: node tools/notify.js + inputs: + title: + type: string + required: true + max-length: 120 + severity: + type: choice + options: [info, warning, critical] + env: + NOTIFY_TOKEN: TEAM_NOTIFY_TOKEN +``` + +### `safe-outputs.jobs.` + +Jobs-style tools declare arbitrary Azure DevOps `steps:`. The compiler wraps +those steps: + +1. `ado-aw execute --custom-phase pre --tool ` filters proposals for this + tool, applies `max`, assigns stable `proposal_id` values, writes + `ADO_AW_SAFE_OUTPUT_PROPOSALS`, and sets `ADO_AW_SAFE_OUTPUTS_STAGED=true` + during dry runs. +2. The component's authored ADO steps run. They read + `$ADO_AW_SAFE_OUTPUT_PROPOSALS` and append one result record per attempted + proposal to `$ADO_AW_SAFE_OUTPUT_RESULTS`. +3. `ado-aw execute --custom-phase post --tool ` validates and enriches + the component results with compiler-owned provenance, sanitizes output, and + emits the standard executed-safe-output artifact. Missing, malformed, or + duplicate result records fail closed. + +Each jobs-style result line must be JSON with `schema_version: 1`, +`proposal_id`, `status`, `message`, and optional `data`: + +```json +{"schema_version":1,"proposal_id":"deploy-thing-0","status":"success","message":"deployment queued"} +``` + +```yaml +safe-outputs: + jobs: + create-service-ticket: + description: Create an incident ticket in the service desk. + max: 2 + inputs: + title: + type: string + required: true + max-length: 160 + priority: + type: choice + options: [low, normal, high] + required: true + env: + SERVICE_DESK_TOKEN: SERVICE_DESK_TOKEN + steps: + - bash: | + set -euo pipefail + : > "$ADO_AW_SAFE_OUTPUT_RESULTS" + while IFS= read -r proposal; do + proposal_id=$(echo "$proposal" | jq -r '.proposal_id') + title=$(echo "$proposal" | jq -r '.title') + jq -cn --arg proposal_id "$proposal_id" --arg title "$title" \ + '{schema_version:1, proposal_id:$proposal_id, status:"success", message:("created ticket for " + $title)}' \ + >> "$ADO_AW_SAFE_OUTPUT_RESULTS" + done < "$ADO_AW_SAFE_OUTPUT_PROPOSALS" + displayName: Create service ticket +``` + +For cross-repository components that ship executor files, the custom executor +job checks out the component repository resource, detaches to the pinned commit +SHA, verifies `HEAD`, then runs the script or wrapped steps. Prompt-only or +configuration-only imports are fully inlined at compile time and do not need a +runtime checkout. + ## Available Safe Output Tools ### comment-on-work-item diff --git a/src/audit/analyzers/safe_outputs.rs b/src/audit/analyzers/safe_outputs.rs index 0d72a722..5aae953a 100644 --- a/src/audit/analyzers/safe_outputs.rs +++ b/src/audit/analyzers/safe_outputs.rs @@ -9,8 +9,8 @@ use std::path::{Path, PathBuf}; use tokio::fs; use crate::audit::model::{ - CreatedItemReport, Finding, RejectedSafeOutputsRollup, SafeOutputExecution, - SafeOutputExecutionItem, SafeOutputStatus, SafeOutputSummary, Severity, + AwInfo, ComponentProvenance, CreatedItemReport, Finding, RejectedSafeOutputsRollup, + SafeOutputExecution, SafeOutputExecutionItem, SafeOutputStatus, SafeOutputSummary, Severity, }; use crate::ndjson::{EXECUTED_NDJSON_FILENAME, SAFE_OUTPUT_FILENAME, read_ndjson_file}; @@ -43,6 +43,16 @@ struct ExecutionRecord { context: Option, result: Option, error: Option, + component: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct ExecutionComponentProvenance { + source: Option, + sha: Option, + manifest_digest: Option, + schema_digest: Option, } #[derive(Debug, Clone)] @@ -70,6 +80,7 @@ pub async fn analyze_safe_outputs( let proposals = load_proposals(proposals_path.as_deref()).await?; let detection = load_detection_verdict(detection_path.as_deref()).await?; let executions = load_execution_records(&executions_paths).await?; + let marker_components = load_marker_custom_components(download_root).await?; let detection_gate_fired = detection.as_ref().is_some_and(DetectionVerdict::gate_fired); let items = if detection_gate_fired { @@ -132,7 +143,7 @@ pub async fn analyze_safe_outputs( }) .unwrap_or_default(); let rollup = build_rollup(summary.as_ref(), execution.as_ref(), detection.as_ref()); - let findings = if detection_gate_fired && proposed_count > 0 { + let mut findings = if detection_gate_fired && proposed_count > 0 { vec![build_detection_finding( detection.as_ref().expect("gate-fired verdict"), proposed_count, @@ -140,6 +151,9 @@ pub async fn analyze_safe_outputs( } else { Vec::new() }; + if let Some(marker_components) = marker_components.as_deref() { + findings.extend(build_provenance_findings(&executions, marker_components)); + } Ok(SafeOutputAnalysis { summary, @@ -201,9 +215,7 @@ async fn load_detection_verdict(path: Option<&Path>) -> anyhow::Result anyhow::Result> { +async fn load_execution_records(paths: &[PathBuf]) -> anyhow::Result> { let mut records = Vec::new(); for path in paths { let values = read_ndjson_file(path).await?; @@ -229,6 +241,53 @@ async fn load_execution_records( Ok(records) } +async fn load_marker_custom_components( + download_root: &Path, +) -> anyhow::Result>> { + // Phase F1 mirrors the compile-time `# ado-aw-metadata` marker into + // staging/aw_info.json, so audit can cross-check provenance locally without + // fetching component sources from the network. + for directory in top_level_dirs_with_prefix(download_root, "agent_outputs_").await? { + for candidate in [ + directory.join("staging").join("aw_info.json"), + directory.join("aw_info.json"), + ] { + if !fs::metadata(&candidate) + .await + .map(|m| m.is_file()) + .unwrap_or(false) + { + continue; + } + let contents = tokio::fs::read_to_string(&candidate) + .await + .with_context(|| format!("Failed to read aw_info file: {}", candidate.display()))?; + let aw_info = serde_json::from_str::(&contents).with_context(|| { + format!("Failed to parse aw_info file: {}", candidate.display()) + })?; + return Ok(Some(aw_info.custom_components)); + } + } + Ok(None) +} + +impl ExecutionComponentProvenance { + fn to_model(&self) -> Option { + Some(ComponentProvenance { + source: normalize_optional_string(self.source.clone())?, + sha: normalize_optional_string(self.sha.clone())?, + manifest_digest: normalize_optional_string(self.manifest_digest.clone())?, + schema_digest: normalize_optional_string(self.schema_digest.clone())?, + }) + } +} + +impl ExecutionRecord { + fn component_provenance(&self) -> Option { + self.component.as_ref()?.to_model() + } +} + fn build_execution_items( proposals: &[ProposalRecord], executions: &[IndexedExecutionRecord], @@ -323,6 +382,7 @@ fn build_item_from_execution( proposal: proposal.proposal.clone(), error: error.clone(), result: execution.result.clone(), + component_provenance: execution.component_provenance(), rejection_reason: rejection_reason_for_status(status, error), applies_to_whole_batch: false, } @@ -337,6 +397,7 @@ fn build_missing_execution_item(proposal: &ProposalRecord) -> SafeOutputExecutio proposal: proposal.proposal.clone(), error: error.clone(), result: None, + component_provenance: None, rejection_reason: error, applies_to_whole_batch: false, } @@ -352,6 +413,7 @@ fn build_unmatched_execution_item(execution: &ExecutionRecord) -> SafeOutputExec proposal: Value::Null, error: error.clone(), result: execution.result.clone(), + component_provenance: execution.component_provenance(), rejection_reason: rejection_reason_for_status(status, error), applies_to_whole_batch: false, } @@ -368,6 +430,7 @@ fn build_gate_rejected_item( proposal: proposal.proposal.clone(), error: None, result: None, + component_provenance: None, rejection_reason: Some(aggregate_reason_key(detection)), applies_to_whole_batch: true, } @@ -470,6 +533,61 @@ fn build_detection_finding(detection: &DetectionVerdict, proposed_count: u64) -> } } +fn build_provenance_findings( + executions: &[IndexedExecutionRecord], + marker_components: &[ComponentProvenance], +) -> Vec { + executions + .iter() + .filter_map(|execution| { + let component = execution.record.component_provenance()?; + cross_check_provenance(&execution.record.name, &component, marker_components) + }) + .collect() +} + +fn cross_check_provenance( + tool: &str, + record_component: &ComponentProvenance, + marker_components: &[ComponentProvenance], +) -> Option { + let Some(expected) = marker_components + .iter() + .find(|component| component.source == record_component.source) + else { + return Some( + crate::audit::findings::custom_component_provenance_mismatch_finding( + tool, + &record_component.source, + None, + record_component, + &["source"], + ), + ); + }; + + let mut mismatched_fields = Vec::new(); + if expected.sha != record_component.sha { + mismatched_fields.push("sha"); + } + if expected.manifest_digest != record_component.manifest_digest { + mismatched_fields.push("manifest_digest"); + } + if expected.schema_digest != record_component.schema_digest { + mismatched_fields.push("schema_digest"); + } + + (!mismatched_fields.is_empty()).then(|| { + crate::audit::findings::custom_component_provenance_mismatch_finding( + tool, + &record_component.source, + Some(expected), + record_component, + &mismatched_fields, + ) + }) +} + fn created_item_from_execution_item(item: &SafeOutputExecutionItem) -> Option { if item.status != SafeOutputStatus::Executed { return None; @@ -661,8 +779,8 @@ fn collect_named_files<'a>( #[cfg(test)] mod tests { use super::{ - CreatedItemReport, EXECUTED_NDJSON_FILENAME, SAFE_OUTPUT_FILENAME, SafeOutputStatus, - Severity, analyze_safe_outputs, + ComponentProvenance, CreatedItemReport, EXECUTED_NDJSON_FILENAME, SAFE_OUTPUT_FILENAME, + SafeOutputStatus, Severity, analyze_safe_outputs, cross_check_provenance, }; use serde_json::{Value, json}; use std::fs; @@ -727,10 +845,133 @@ mod tests { .iter() .all(|item| item.status == SafeOutputStatus::Executed) ); + assert!( + execution + .items + .iter() + .all(|item| item.component_provenance.is_none()), + "built-in records must not gain custom component provenance" + ); assert!(analysis.rollup.is_none()); assert!(analysis.findings.is_empty()); } + #[tokio::test] + async fn custom_execution_record_populates_component_provenance() { + let temp_dir = TempDir::new().expect("create temp dir"); + write_ndjson( + &temp_dir + .path() + .join("agent_outputs_42") + .join("staging") + .join(SAFE_OUTPUT_FILENAME), + &[json!({"name": "custom_notify", "context": "custom-1"})], + ); + write_json( + &temp_dir + .path() + .join("agent_outputs_42") + .join("staging") + .join("aw_info.json"), + &json!({ + "schema": "ado-aw/aw_info/1", + "custom_components": [{ + "source": "org/repo/components/notify", + "sha": "0123456789abcdef0123456789abcdef01234567", + "manifest_digest": "sha256:manifest", + "schema_digest": "sha256:schema" + }] + }), + ); + write_ndjson( + &temp_dir + .path() + .join("safe_outputs") + .join(EXECUTED_NDJSON_FILENAME), + &[json!({ + "schema_version": 1, + "tool": "custom_notify", + "proposal_id": "custom-1", + "proposal_index": 0, + "name": "custom_notify", + "status": "succeeded", + "context": "custom-1", + "message": "ok", + "component": { + "source": "org/repo/components/notify", + "sha": "0123456789abcdef0123456789abcdef01234567", + "manifest_digest": "sha256:manifest", + "schema_digest": "sha256:schema" + }, + "attempt": { + "number": 1, + "staged": false, + "started_at": "2026-07-13T00:00:00Z", + "ended_at": "2026-07-13T00:00:01Z" + }, + "result": {"status": "ok"} + })], + ); + + let analysis = analyze_safe_outputs(temp_dir.path()) + .await + .expect("analyze custom safe output"); + + let execution = analysis.execution.expect("execution"); + assert_eq!(execution.items.len(), 1); + assert_eq!( + execution.items[0].component_provenance, + Some(ComponentProvenance { + source: String::from("org/repo/components/notify"), + sha: String::from("0123456789abcdef0123456789abcdef01234567"), + manifest_digest: String::from("sha256:manifest"), + schema_digest: String::from("sha256:schema"), + }) + ); + assert!( + analysis.findings.is_empty(), + "matching marker/runtime provenance should not emit findings" + ); + } + + #[test] + fn cross_check_provenance_accepts_matching_marker_component() { + let component = ComponentProvenance { + source: String::from("org/repo/components/notify"), + sha: String::from("sha-a"), + manifest_digest: String::from("manifest-a"), + schema_digest: String::from("schema-a"), + }; + + assert!( + cross_check_provenance("custom_notify", &component, std::slice::from_ref(&component)) + .is_none() + ); + } + + #[test] + fn cross_check_provenance_reports_mismatched_sha() { + let marker = ComponentProvenance { + source: String::from("org/repo/components/notify"), + sha: String::from("sha-expected"), + manifest_digest: String::from("manifest-a"), + schema_digest: String::from("schema-a"), + }; + let runtime = ComponentProvenance { + source: String::from("org/repo/components/notify"), + sha: String::from("sha-actual"), + manifest_digest: String::from("manifest-a"), + schema_digest: String::from("schema-a"), + }; + + let finding = cross_check_provenance("custom_notify", &runtime, &[marker]) + .expect("mismatched sha should emit finding"); + assert_eq!(finding.severity, Severity::High); + assert!(finding.title.contains("custom_notify")); + assert!(finding.description.contains("sha-expected")); + assert!(finding.description.contains("sha-actual")); + } + #[tokio::test] async fn execution_records_aggregate_across_split_artifacts() { // Manual-review split: automatic outputs land in `safe_outputs/`, @@ -753,14 +994,18 @@ mod tests { .path() .join("safe_outputs") .join(EXECUTED_NDJSON_FILENAME), - &[json!({"name": "add_pr_comment", "status": "succeeded", "context": "c-1", "result": {"status": "ok"}})], + &[ + json!({"name": "add_pr_comment", "status": "succeeded", "context": "c-1", "result": {"status": "ok"}}), + ], ); write_ndjson( &temp_dir .path() .join("safe_outputs_reviewed") .join(EXECUTED_NDJSON_FILENAME), - &[json!({"name": "create_pull_request", "status": "succeeded", "context": "pr-1", "result": {"number": 9}})], + &[ + json!({"name": "create_pull_request", "status": "succeeded", "context": "pr-1", "result": {"number": 9}}), + ], ); let analysis = analyze_safe_outputs(temp_dir.path()) diff --git a/src/audit/findings.rs b/src/audit/findings.rs index 4f50ebbc..2986f88b 100644 --- a/src/audit/findings.rs +++ b/src/audit/findings.rs @@ -1,6 +1,46 @@ use std::collections::BTreeMap; -use crate::audit::model::{AuditData, Finding, JobData, Recommendation, Severity}; +use crate::audit::model::{ + AuditData, ComponentProvenance, Finding, JobData, Recommendation, Severity, +}; + +/// Build a finding for a custom safe-output component whose runtime +/// provenance disagrees with the compile-time ado-aw metadata marker. +pub fn custom_component_provenance_mismatch_finding( + tool: &str, + source: &str, + expected: Option<&ComponentProvenance>, + actual: &ComponentProvenance, + mismatched_fields: &[&str], +) -> Finding { + let expected_text = expected.map_or_else( + || String::from("no compile-time marker entry"), + |expected| { + format!( + "sha={}, manifest_digest={}, schema_digest={}", + expected.sha, expected.manifest_digest, expected.schema_digest + ) + }, + ); + let fields = if mismatched_fields.is_empty() { + String::from("source") + } else { + mismatched_fields.join(", ") + }; + + Finding { + category: String::from("safe_outputs"), + severity: Severity::High, + title: format!("Custom component provenance mismatch for {tool}"), + description: format!( + "Runtime provenance for custom safe-output tool '{tool}' from '{source}' does not match the compile-time ado-aw metadata marker ({fields}). Expected: {expected_text}. Actual: sha={}, manifest_digest={}, schema_digest={}.", + actual.sha, actual.manifest_digest, actual.schema_digest + ), + impact: Some(String::from( + "A custom safe-output executor may have run different component code or schema than the compiled workflow declared.", + )), + } +} /// Aggregate findings + recommendations from every populated section /// of `AuditData`. Pure function; does not mutate the input. diff --git a/src/audit/model.rs b/src/audit/model.rs index 07b96931..e6ff4b92 100644 --- a/src/audit/model.rs +++ b/src/audit/model.rs @@ -176,6 +176,27 @@ pub struct AwInfo { /// Compiler version that produced the workflow. #[serde(skip_serializing_if = "Option::is_none")] pub compiler_version: Option, + /// Compile-time custom component provenance emitted by ado-aw metadata. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub custom_components: Vec, +} + +/// Custom safe-output component provenance captured at compile and execution time. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ComponentProvenance { + /// Import source, for example `org/repo/path`. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source: String, + /// Resolved component commit SHA. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sha: String, + /// SHA-256 digest of the component manifest. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub manifest_digest: String, + /// SHA-256 digest of the component schema. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub schema_digest: String, } /// Aggregate numeric metrics for the audited run. @@ -755,6 +776,9 @@ pub struct SafeOutputExecutionItem { /// Optional execution result payload. #[serde(skip_serializing_if = "Option::is_none")] pub result: Option, + /// Custom component provenance for custom safe-output tools. + #[serde(skip_serializing_if = "Option::is_none")] + pub component_provenance: Option, /// Optional rejection reason emitted by detection or execution. #[serde(skip_serializing_if = "Option::is_none")] pub rejection_reason: Option, @@ -919,6 +943,7 @@ mod tests { source: Some(String::from("agents/security-scan.md")), target: Some(String::from("standalone")), compiler_version: Some(String::from("0.30.2")), + custom_components: Vec::new(), }), }, task_domain: Some(TaskDomainInfo { @@ -982,6 +1007,7 @@ mod tests { proposal: json!({"title": "Fix pipeline", "repository": "repo"}), error: Some(String::from("Batch blocked by detection gate")), result: Some(json!({"status": "blocked"})), + component_provenance: None, rejection_reason: Some(String::from("prompt_injection")), applies_to_whole_batch: true, }], @@ -1139,6 +1165,39 @@ mod tests { assert_eq!(keys_sorted, vec!["downloaded_files", "metrics", "overview"]); } + #[test] + fn safe_output_execution_item_serializes_component_provenance_only_when_present() { + let without_component = SafeOutputExecutionItem { + tool: String::from("noop"), + status: SafeOutputStatus::Executed, + proposal: json!({"name": "noop"}), + ..Default::default() + }; + let value = serde_json::to_value(&without_component).expect("serialize without component"); + assert!( + value.get("component_provenance").is_none(), + "None provenance must not change built-in audit JSON" + ); + + let with_component = SafeOutputExecutionItem { + tool: String::from("custom_notify"), + status: SafeOutputStatus::Executed, + proposal: json!({"message": "done"}), + component_provenance: Some(ComponentProvenance { + source: String::from("org/repo/components/notify"), + sha: String::from("0123456789abcdef0123456789abcdef01234567"), + manifest_digest: String::from("sha256:manifest"), + schema_digest: String::from("sha256:schema"), + }), + ..Default::default() + }; + let json = serde_json::to_string(&with_component).expect("serialize with component"); + assert!(json.contains("component_provenance")); + let round_tripped: SafeOutputExecutionItem = + serde_json::from_str(&json).expect("deserialize with component"); + assert_eq!(round_tripped, with_component); + } + #[test] fn matches_ir_id_accepts_bare_and_single_level_qualified_names() { let bare = JobData { diff --git a/src/audit/render/console.rs b/src/audit/render/console.rs index 76f37cd5..941d9a3a 100644 --- a/src/audit/render/console.rs +++ b/src/audit/render/console.rs @@ -1171,6 +1171,7 @@ By threat: source: Some("agents/my-agent.md".to_string()), target: Some("standalone".to_string()), compiler_version: Some("0.30.2".to_string()), + custom_components: Vec::new(), }), }, task_domain: Some(TaskDomainInfo { @@ -1235,6 +1236,7 @@ By threat: proposal: json!({"title": "Fix bug"}), error: Some("Blocked by detection gate".to_string()), result: Some(json!({"status": "blocked"})), + component_provenance: None, rejection_reason: Some("prompt_injection".to_string()), applies_to_whole_batch: true, }], diff --git a/src/audit/render/json.rs b/src/audit/render/json.rs index 24b279d7..09b3e229 100644 --- a/src/audit/render/json.rs +++ b/src/audit/render/json.rs @@ -65,6 +65,7 @@ mod tests { source: Some(String::from("agents/security-scan.md")), target: Some(String::from("standalone")), compiler_version: Some(String::from("0.30.2")), + custom_components: Vec::new(), }), }, task_domain: Some(TaskDomainInfo { @@ -128,6 +129,7 @@ mod tests { proposal: json!({"title": "Fix pipeline", "repository": "repo"}), error: Some(String::from("Batch blocked by detection gate")), result: Some(json!({"status": "blocked"})), + component_provenance: None, rejection_reason: Some(String::from("prompt_injection")), applies_to_whole_batch: true, }], diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 06b20342..e41ee9ab 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -60,7 +60,7 @@ //! - `Teardown` (optional): user `teardown:` steps. //! - `Conclusion` (optional): post-run reporting / work-item filing. -use anyhow::Result; +use anyhow::{Context, Result}; use std::path::Path; use super::common::{ @@ -94,6 +94,7 @@ use super::types::{ /// The `safe-outputs:` key for the create-pull-request tool. Matches the kebab /// name `FrontMatter::create_pr_config`/`partition_safe_outputs_by_approval` use. const CREATE_PULL_REQUEST_TOOL: &str = "create-pull-request"; +const CUSTOM_PROPOSALS_STEP_ID: &str = "customProposals"; /// Built pipeline context — the result of running every validation, /// scalar computation, extension declaration fanout, and canonical- @@ -307,7 +308,11 @@ pub(crate) fn build_pipeline_context( // ─── Top-level pipeline fields ──────────────────────────────── let parameters = build_parameters(front_matter)?; - let resources = build_resources(&front_matter.repositories, &front_matter.on_config); + let resources = build_resources( + front_matter, + &front_matter.repositories, + &front_matter.on_config, + )?; let triggers = build_triggers(&front_matter.on_config, front_matter)?; // ─── Extension declaration fanout ───────────────────────────── @@ -428,44 +433,62 @@ pub(crate) fn build_canonical_jobs( if let Some(review) = build_manual_review_job(front_matter, cfg, &p)? { jobs.push(review); } + let custom_defs = collect_custom_safe_output_job_defs(front_matter, &p)?; + let custom_job_ids: Vec = custom_defs.iter().map(|d| d.job_id.clone()).collect(); + let custom_reviewed_job_ids: Vec = custom_defs + .iter() + .filter(|d| d.reviewed) + .map(|d| d.job_id.clone()) + .collect(); + for def in &custom_defs { + jobs.push(build_custom_safe_output_job(def, front_matter, cfg)?); + } // Safe-outputs execution. With manual review, execution may split into an // automatic job (runs immediately) and a reviewed job (gated behind the // ManualReview approval). Partition decides the shape: // - no reviewed tools → single default job (unchanged) // - all reviewed tools → single default job, gated by ManualReview // - mixed (auto + reviewed) → auto job + reviewed job - let (auto, reviewed) = front_matter.partition_safe_outputs_by_approval(); + let (auto_all, reviewed_all) = front_matter.partition_safe_outputs_by_approval(); + let custom_tool_names = front_matter.custom_safe_output_tool_names(); + let custom_tool_set: std::collections::HashSet<&str> = + custom_tool_names.iter().map(String::as_str).collect(); + let auto: Vec = auto_all + .into_iter() + .filter(|tool| !custom_tool_set.contains(tool.as_str())) + .collect(); + let reviewed: Vec = reviewed_all + .into_iter() + .filter(|tool| !custom_tool_set.contains(tool.as_str())) + .collect(); // Which variant actually runs `create-pull-request` (and thus needs the // `prepare-pr-base` fetch/deepen — issue #1453). In a split it lives in // exactly one variant; the other filters it out, so only the running // variant should pay for the bundle download + prepare step. let create_pr_configured = front_matter.create_pr_config().is_some(); let create_pr_reviewed = reviewed.iter().any(|t| t == CREATE_PULL_REQUEST_TOOL); + let safeoutputs_waits_for_review = !reviewed.is_empty() && auto.is_empty(); if reviewed.is_empty() || auto.is_empty() { jobs.push(build_safeoutputs_job( front_matter, cfg, &p, - &SafeOutputsVariant::default_single(create_pr_configured), + &SafeOutputsVariant::default_single(create_pr_configured) + .with_excluded_tools(&custom_tool_names), )?); } else { jobs.push(build_safeoutputs_job( front_matter, cfg, &p, - &SafeOutputsVariant::automatic( - &reviewed, - create_pr_configured && !create_pr_reviewed, - ), + &SafeOutputsVariant::automatic(&reviewed, create_pr_configured && !create_pr_reviewed) + .with_excluded_tools(&custom_tool_names), )?); jobs.push(build_safeoutputs_job( front_matter, cfg, &p, - &SafeOutputsVariant::reviewed( - &reviewed, - create_pr_configured && create_pr_reviewed, - ), + &SafeOutputsVariant::reviewed(&reviewed, create_pr_configured && create_pr_reviewed), )?); } if let Some(teardown) = build_teardown_job(front_matter, cfg, &p)? { @@ -477,7 +500,13 @@ pub(crate) fn build_canonical_jobs( // Wire dependsOn between jobs (graph pass also derives but // explicit edges make the YAML match committed lock files). - wire_explicit_dependencies(&mut jobs, &p)?; + wire_explicit_dependencies( + &mut jobs, + &p, + &custom_reviewed_job_ids, + &custom_job_ids, + safeoutputs_waits_for_review, + )?; Ok(jobs) } @@ -501,6 +530,14 @@ impl<'a> JobPrefix<'a> { _ => JobId::new(base), } } + + fn custom_id(&self, tool: &str) -> Result { + let base = format!("Custom_{}", ado_identifier_suffix(tool)); + match self.0 { + Some(prefix) => JobId::new(format!("{prefix}_{base}")), + None => JobId::new(base), + } + } } /// Aggregates the precomputed scalars + YAML fragments threaded into @@ -649,7 +686,11 @@ fn yaml_value_as_string(v: &serde_yaml::Value) -> String { } } -fn build_resources(repos: &[RepoCfg], on: &Option) -> Resources { +fn build_resources( + front_matter: &FrontMatter, + repos: &[RepoCfg], + on: &Option, +) -> Result { let mut repositories: Vec = vec![RepositoryResource::SelfRepo { clean: true, submodules: true, @@ -660,8 +701,10 @@ fn build_resources(repos: &[RepoCfg], on: &Option) -> Resources { kind: r.repo_type.clone(), name: r.name.clone(), r#ref: Some(r.repo_ref.clone()), + endpoint: r.endpoint.clone(), }); } + repositories.extend(custom_repository_resources(front_matter)?); // Pipeline-completion triggers surface as `resources.pipelines[]`. // Mirrors legacy `generate_pipeline_resources`. let mut pipelines: Vec = Vec::new(); @@ -686,10 +729,10 @@ fn build_resources(repos: &[RepoCfg], on: &Option) -> Resources { trigger: true, }); } - Resources { + Ok(Resources { repositories, pipelines, - } + }) } fn build_triggers(on: &Option, front_matter: &FrontMatter) -> Result { @@ -935,9 +978,18 @@ fn build_agent_job( push_raw_yaml_if_nonempty(&mut steps, &cfg.awf_path_step_yaml)?; // 15. SafeOutputs HTTP server + let custom_tool_schemas = super::custom_tools::generate_custom_tool_schemas(front_matter)?; + let custom_tools_json = if custom_tool_schemas.is_empty() { + None + } else { + Some(super::custom_tools::custom_tools_json( + &custom_tool_schemas, + )?) + }; steps.push(Step::Bash(start_safeoutputs_server_step( &cfg.enabled_tools_args, &cfg.working_directory, + custom_tools_json.as_deref(), ))); // 16. MCP Gateway (MCPG) @@ -1276,6 +1328,13 @@ fn build_detection_job( &reviewed_tools, ))); } + let custom_tools = front_matter.custom_safe_output_tool_names(); + if !custom_tools.is_empty() { + steps.push(Step::Bash(detect_custom_proposals_step( + &cfg.working_directory, + &custom_tools, + )?)); + } // Copy logs steps.push(Step::Bash(copy_logs_step(&cfg.engine_log_dir, true))); // Publish @@ -1354,6 +1413,16 @@ impl SafeOutputsVariant { is_reviewed: true, } } + + fn with_excluded_tools(mut self, excluded: &[String]) -> Self { + if excluded.is_empty() { + return self; + } + let mut flags = self.filter_args; + flags.push_str(&filter_flags("--exclude", excluded)); + self.filter_args = flags; + self + } } /// Build a ` -- ` run for `ado-aw execute` (leading space so it @@ -1372,6 +1441,562 @@ fn filter_flags(flag: &str, tools: &[String]) -> String { s } +#[derive(Debug, Clone)] +enum CustomSafeOutputKind { + Scripts { entrypoint: String }, + Jobs { steps: Vec }, +} + +#[derive(Debug, Clone)] +struct CustomSafeOutputJobDef { + name: String, + job_id: JobId, + reviewed: bool, + max: Option, + env: Vec<(String, String)>, + kind: CustomSafeOutputKind, + component: Option, + schema_digest: String, +} + +#[derive(Debug, Clone)] +struct CustomComponentRuntime { + alias: String, + checkout_dir: String, + source: String, + sha: String, + manifest_digest: Option, +} + +fn ado_identifier_suffix(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for ch in raw.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch); + } else { + out.push('_'); + } + } + if out + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_') + { + out + } else { + format!("_{out}") + } +} + +fn custom_tool_output_var(tool: &str) -> String { + format!("HasCustom_{}", ado_identifier_suffix(tool)) +} + +fn collect_custom_safe_output_job_defs( + front_matter: &FrontMatter, + prefix: &JobPrefix<'_>, +) -> Result> { + let (_, reviewed) = front_matter.partition_safe_outputs_by_approval(); + let reviewed: std::collections::HashSet<&str> = reviewed.iter().map(String::as_str).collect(); + let schema_digests = custom_schema_digests(front_matter)?; + let mut defs = Vec::new(); + + for section in ["scripts", "jobs"] { + let Some(section_value) = front_matter.safe_outputs.get(section) else { + continue; + }; + let section_obj = section_value.as_object().ok_or_else(|| { + anyhow::anyhow!("safe-outputs.{section} must be a mapping of tool definitions") + })?; + let mut names: Vec<&String> = section_obj.keys().collect(); + names.sort(); + for tool_name in names { + let tool_value = section_obj + .get(tool_name) + .expect("tool name collected from map keys"); + let tool_obj = tool_value.as_object().ok_or_else(|| { + anyhow::anyhow!("safe-outputs.{section}.{tool_name} must be a mapping") + })?; + let env = parse_custom_env(section, tool_name, tool_obj.get("env"))?; + let max = match tool_obj.get("max") { + Some(v) => Some(v.as_u64().ok_or_else(|| { + anyhow::anyhow!( + "safe-outputs.{section}.{tool_name}.max must be a positive integer" + ) + })?), + None => None, + }; + let kind = if section == "scripts" { + let entrypoint = tool_obj + .get("entrypoint") + .or_else(|| tool_obj.get("run")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + anyhow::anyhow!( + "safe-outputs.scripts.{tool_name} requires `run` or `entrypoint`" + ) + })? + .to_string(); + CustomSafeOutputKind::Scripts { entrypoint } + } else { + let steps = tool_obj + .get("steps") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + anyhow::anyhow!("safe-outputs.jobs.{tool_name}.steps must be a list") + })? + .iter() + .map(|v| serde_yaml::to_value(v).context("failed to convert custom job step")) + .collect::>>()?; + for step in &steps { + if let Some(Err(message)) = super::ir::tasks::parse::validate_task_step(step) { + eprintln!( + "Warning: safe-outputs.jobs.{tool_name}.steps contains an invalid task input: {message}" + ); + } + } + CustomSafeOutputKind::Jobs { steps } + }; + let schema_digest = schema_digests + .get(tool_name) + .cloned() + .unwrap_or_else(|| crate::hash::sha256_hex(b"{}")); + defs.push(CustomSafeOutputJobDef { + name: tool_name.clone(), + job_id: prefix.custom_id(tool_name)?, + reviewed: reviewed.contains(tool_name.as_str()), + max, + env, + kind, + component: parse_custom_component_runtime(tool_obj), + schema_digest, + }); + } + } + Ok(defs) +} + +fn custom_schema_digests( + front_matter: &FrontMatter, +) -> Result> { + let mut out = std::collections::HashMap::new(); + for schema in super::custom_tools::generate_custom_tool_schemas(front_matter)? { + let bytes = serde_json::to_vec(&schema.input_schema) + .context("failed to serialize custom tool schema for digest")?; + out.insert(schema.name, crate::hash::sha256_hex(&bytes)); + } + Ok(out) +} + +fn parse_custom_env( + section: &str, + tool_name: &str, + env: Option<&serde_json::Value>, +) -> Result> { + let Some(env) = env else { + return Ok(Vec::new()); + }; + let env_obj = env.as_object().ok_or_else(|| { + anyhow::anyhow!("safe-outputs.{section}.{tool_name}.env must be a mapping") + })?; + let mut pairs = Vec::new(); + for (name, value) in env_obj { + anyhow::ensure!( + crate::validate::is_valid_env_var_name(name), + "safe-outputs.{section}.{tool_name}.env key `{name}` is not a valid environment variable name" + ); + let var = value.as_str().ok_or_else(|| { + anyhow::anyhow!( + "safe-outputs.{section}.{tool_name}.env.{name} must name an ADO variable" + ) + })?; + anyhow::ensure!( + crate::validate::is_valid_ado_variable_name(var), + "safe-outputs.{section}.{tool_name}.env.{name} must be a valid ADO variable name" + ); + pairs.push((name.clone(), var.to_string())); + } + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(pairs) +} + +fn parse_custom_component_runtime( + tool_obj: &serde_json::Map, +) -> Option { + let source = tool_obj + .get("component-source") + .or_else(|| tool_obj.get("source")) + .and_then(serde_json::Value::as_str)?; + let sha = tool_obj + .get("component-sha") + .or_else(|| tool_obj.get("sha")) + .and_then(serde_json::Value::as_str)?; + let mut parts = source.splitn(3, '/'); + let owner = parts.next()?; + let repo = parts.next()?; + let _path = parts.next()?; + if owner.is_empty() || repo.is_empty() { + return None; + } + let alias = super::imports::alias::alias_identifier(owner, repo); + Some(CustomComponentRuntime { + checkout_dir: format!("$(Build.SourcesDirectory)/{alias}"), + alias, + source: source.to_string(), + sha: sha.to_string(), + manifest_digest: tool_obj + .get("manifest-digest") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + }) +} + +fn custom_repository_resources(front_matter: &FrontMatter) -> Result> { + let mut resources = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for section in ["scripts", "jobs"] { + let Some(section_value) = front_matter.safe_outputs.get(section) else { + continue; + }; + let Some(section_obj) = section_value.as_object() else { + continue; + }; + for tool_value in section_obj.values() { + let Some(tool_obj) = tool_value.as_object() else { + continue; + }; + let Some(component) = parse_custom_component_runtime(tool_obj) else { + continue; + }; + if !seen.insert(component.alias.clone()) { + continue; + } + let mut parts = component.source.splitn(3, '/'); + let owner = parts.next().unwrap_or_default(); + let repo = parts.next().unwrap_or_default(); + resources.push(RepositoryResource::Named { + identifier: component.alias, + kind: "git".to_string(), + name: format!("{owner}/{repo}"), + r#ref: Some("refs/heads/main".to_string()), + endpoint: None, + }); + } + } + Ok(resources) +} + +fn build_custom_safe_output_job( + def: &CustomSafeOutputJobDef, + front_matter: &FrontMatter, + cfg: &StandaloneCtx, +) -> Result { + let mut steps = Vec::new(); + steps.push(checkout_self_step(&cfg.self_checkout_fetch)); + if let Some(component) = &def.component { + steps.push(Step::Checkout(CheckoutStep { + repository: CheckoutRepo::Named(component.alias.clone()), + clean: None, + submodules: None, + fetch_depth: Some(1), + fetch_tags: Some(false), + persist_credentials: None, + })); + steps.push(Step::Bash(verify_custom_component_checkout_step(component))); + } + steps.push(Step::Download(DownloadStep { + source: "current".to_string(), + artifact: "analyzed_outputs_$(Build.BuildId)".to_string(), + condition: None, + })); + if let Some(auth) = feed_auth_step(front_matter.supply_chain()) { + steps.push(auth); + } + steps.extend(download_compiler_step( + &cfg.compiler_version, + front_matter.supply_chain(), + )); + steps.push(Step::Bash(prepare_custom_executor_binary_step())); + + match &def.kind { + CustomSafeOutputKind::Scripts { entrypoint } => { + let config_path = format!("$(Agent.TempDirectory)/ado-aw-custom/{}.json", def.name); + let cwd = def + .component + .as_ref() + .map(|c| c.checkout_dir.clone()) + .unwrap_or_else(|| cfg.working_directory.clone()); + steps.push(Step::Bash(write_custom_scripts_config_step( + def, + entrypoint, + &cwd, + &config_path, + )?)); + steps.push(Step::Bash(custom_scripts_execute_step( + &cfg.source_path, + &config_path, + &def.env, + ))); + } + CustomSafeOutputKind::Jobs { + steps: component_steps, + } => { + let proposals_path = format!("$(Agent.TempDirectory)/proposals-{}.json", def.name); + let results_path = format!("$(Agent.TempDirectory)/results-{}.json", def.name); + steps.push(Step::Bash(custom_jobs_pre_step( + &cfg.source_path, + &def.name, + &proposals_path, + &def.env, + ))); + for step in component_steps { + steps.push(Step::RawYaml(component_step_with_custom_env( + step, + &def.env, + &proposals_path, + &results_path, + )?)); + } + steps.push(Step::Bash(custom_jobs_post_step( + &cfg.source_path, + def, + &proposals_path, + &results_path, + ))); + } + } + + let custom_pool = if def.reviewed { + cfg.pools.safe_outputs_reviewed.clone() + } else { + cfg.pools.safe_outputs.clone() + }; + let mut job = Job::new( + def.job_id.clone(), + format!("Custom safe output: {}", def.name), + custom_pool, + ); + job.steps = steps; + job.condition = Some(custom_job_condition(def)?); + Ok(job) +} + +fn custom_job_condition(def: &CustomSafeOutputJobDef) -> Result { + let mut parts = vec![ + Condition::Succeeded, + Condition::Eq( + Expr::StepOutput(OutputRef::new( + StepId::new("threatAnalysis")?, + "SafeToProcess", + )), + Expr::Literal("true".to_string()), + ), + Condition::Eq( + Expr::StepOutput(OutputRef::new( + StepId::new(CUSTOM_PROPOSALS_STEP_ID)?, + custom_tool_output_var(&def.name), + )), + Expr::Literal("true".to_string()), + ), + ]; + if def.reviewed { + parts.push(Condition::Eq( + Expr::StepOutput(OutputRef::new( + StepId::new("reviewedProposals")?, + "HasReviewedProposals", + )), + Expr::Literal("true".to_string()), + )); + } + Ok(Condition::And(parts)) +} + +fn verify_custom_component_checkout_step(component: &CustomComponentRuntime) -> BashStep { + let script = format!( + "set -euo pipefail\n\ + git -C {dir} checkout --detach {sha}\n\ + ACTUAL_SHA=$(git -C {dir} rev-parse HEAD)\n\ + if [ \"$ACTUAL_SHA\" != {sha} ]; then\n \ + echo \"##vso[task.complete result=Failed]custom component checkout resolved $ACTUAL_SHA, expected {sha}\"\n \ + exit 1\n\ + fi\n\ + echo \"Verified custom component checkout at $ACTUAL_SHA\"\n", + dir = shell_quote(&component.checkout_dir), + sha = shell_quote(&component.sha), + ); + bash("Verify custom component checkout", script) +} + +fn prepare_custom_executor_binary_step() -> BashStep { + bash( + "Prepare custom safe-output executor", + "mkdir -p /tmp/awf-tools\n\ + AGENTIC_PIPELINES_PATH=\"$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw\"\n\ + chmod +x \"$AGENTIC_PIPELINES_PATH\"\n\ + cp \"$AGENTIC_PIPELINES_PATH\" /tmp/awf-tools/ado-aw\n\ + chmod +x /tmp/awf-tools/ado-aw\n", + ) +} + +fn write_custom_scripts_config_step( + def: &CustomSafeOutputJobDef, + entrypoint: &str, + cwd: &str, + config_path: &str, +) -> Result { + let mut tool = serde_json::Map::new(); + tool.insert( + "entrypoint".to_string(), + serde_json::Value::String(entrypoint.to_string()), + ); + tool.insert( + "cwd".to_string(), + serde_json::Value::String(cwd.to_string()), + ); + if let Some(max) = def.max { + tool.insert( + "max".to_string(), + serde_json::Value::Number(serde_json::Number::from(max)), + ); + } + let mut tools = serde_json::Map::new(); + tools.insert(def.name.clone(), serde_json::Value::Object(tool)); + let mut root = serde_json::Map::new(); + root.insert("tools".to_string(), serde_json::Value::Object(tools)); + let config = serde_json::Value::Object(root); + let json = serde_json::to_string_pretty(&config) + .context("failed to serialize custom scripts config")?; + let script = format!( + "mkdir -p \"$(Agent.TempDirectory)/ado-aw-custom\"\n\ + cat > {config_path} << 'ADO_AW_CUSTOM_CONFIG_JSON'\n\ +{json}\n\ + ADO_AW_CUSTOM_CONFIG_JSON\n\ + python3 -m json.tool {config_path} > /dev/null\n", + config_path = shell_quote(config_path) + ); + Ok(bash("Write custom safe-output config", script)) +} + +fn custom_scripts_execute_step( + source_path: &str, + config_path: &str, + env: &[(String, String)], +) -> BashStep { + let script = format!( + "/tmp/awf-tools/ado-aw execute --source {source} --safe-output-dir \"$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)\" --custom-config {config}\n", + source = shell_quote(source_path), + config = shell_quote(config_path) + ); + with_custom_secret_env(bash("Execute custom safe output", script), env) +} + +fn custom_jobs_pre_step( + source_path: &str, + tool: &str, + proposals_path: &str, + env: &[(String, String)], +) -> BashStep { + let script = format!( + "/tmp/awf-tools/ado-aw execute --source {source} --safe-output-dir \"$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)\" --custom-phase pre --tool {tool} --proposals-out {proposals}\n", + source = shell_quote(source_path), + tool = shell_quote(tool), + proposals = shell_quote(proposals_path) + ); + with_custom_secret_env(bash("Prepare custom safe-output proposals", script), env) +} + +fn custom_jobs_post_step( + source_path: &str, + def: &CustomSafeOutputJobDef, + proposals_path: &str, + results_path: &str, +) -> BashStep { + let mut provenance_args = String::new(); + if let Some(component) = &def.component { + provenance_args.push_str(&format!( + " --component-sha {} --component-source {}", + shell_quote(&component.sha), + shell_quote(&component.source) + )); + if let Some(digest) = &component.manifest_digest { + provenance_args.push_str(&format!(" --manifest-digest {}", shell_quote(digest))); + } + } + provenance_args.push_str(&format!( + " --schema-digest {}", + shell_quote(&def.schema_digest) + )); + let script = format!( + "/tmp/awf-tools/ado-aw execute --source {source} --safe-output-dir \"$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)\" --custom-phase post --tool {tool} --results-in {results}{provenance_args}\n", + source = shell_quote(source_path), + tool = shell_quote(&def.name), + results = shell_quote(results_path) + ); + with_custom_secret_env( + bash("Finalize custom safe-output results", script) + .with_env( + "ADO_AW_SAFE_OUTPUT_PROPOSALS", + EnvValue::literal(proposals_path.to_string()), + ) + .with_env( + "ADO_AW_SAFE_OUTPUT_RESULTS", + EnvValue::literal(results_path.to_string()), + ), + &def.env, + ) +} + +fn with_custom_secret_env(mut step: BashStep, env: &[(String, String)]) -> BashStep { + for (name, var) in env { + step = step.with_env(name.clone(), EnvValue::secret(var.clone())); + } + step +} + +fn component_step_with_custom_env( + step: &serde_yaml::Value, + custom_env: &[(String, String)], + proposals_path: &str, + results_path: &str, +) -> Result { + let mut step = step.clone(); + let mapping = step.as_mapping_mut().ok_or_else(|| { + anyhow::anyhow!("safe-outputs.jobs..steps entries must be YAML mappings") + })?; + let env_key = serde_yaml::Value::String("env".to_string()); + if !mapping.contains_key(&env_key) { + mapping.insert( + env_key.clone(), + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()), + ); + } + let env_value = mapping + .get_mut(&env_key) + .expect("env key inserted above when absent"); + let env_map = env_value.as_mapping_mut().ok_or_else(|| { + anyhow::anyhow!("safe-outputs.jobs..steps env blocks must be mappings") + })?; + env_map.insert( + serde_yaml::Value::String("ADO_AW_SAFE_OUTPUT_PROPOSALS".to_string()), + serde_yaml::Value::String(proposals_path.to_string()), + ); + env_map.insert( + serde_yaml::Value::String("ADO_AW_SAFE_OUTPUT_RESULTS".to_string()), + serde_yaml::Value::String(results_path.to_string()), + ); + for (name, var) in custom_env { + env_map.insert( + serde_yaml::Value::String(name.clone()), + serde_yaml::Value::String(format!("$({var})")), + ); + } + step_to_raw_yaml_string(&step) +} + +fn shell_quote(raw: &str) -> String { + format!("'{}'", raw.replace('\'', "'\\''")) +} + /// Build the `(dir, target-branch)` pairs the `prepare-pr-base` bundle must /// fetch/deepen — one per allowed `create-pull-request` repo, mirroring /// `mcp.rs::resolve_git_dir_for_patch`: `working_directory` (for `self`) and @@ -2039,7 +2664,13 @@ fi\n" /// from the same `prefix`, so a failure here would indicate an /// invalid `JobPrefix` reaching this function — the typed error is /// preferable to a panic for any future caller. -fn wire_explicit_dependencies(jobs: &mut [Job], prefix: &JobPrefix<'_>) -> Result<()> { +fn wire_explicit_dependencies( + jobs: &mut [Job], + prefix: &JobPrefix<'_>, + custom_reviewed_job_ids: &[JobId], + custom_job_ids: &[JobId], + safeoutputs_waits_for_review: bool, +) -> Result<()> { let setup_id = prefix.id("Setup")?; let agent_id = prefix.id("Agent")?; let detection_id = prefix.id("Detection")?; @@ -2050,7 +2681,6 @@ fn wire_explicit_dependencies(jobs: &mut [Job], prefix: &JobPrefix<'_>) -> Resul let conclusion_id = prefix.id("Conclusion")?; let has_setup = jobs.iter().any(|j| j.id == setup_id); let has_teardown = jobs.iter().any(|j| j.id == teardown_id); - let has_review = jobs.iter().any(|j| j.id == manualreview_id); // The reviewed execution job only exists in the mixed (split) case. let has_reviewed_job = jobs.iter().any(|j| j.id == reviewed_id); for j in jobs.iter_mut() { @@ -2062,12 +2692,22 @@ fn wire_explicit_dependencies(jobs: &mut [Job], prefix: &JobPrefix<'_>) -> Resul // Agentless gate: depends on Detection (its condition reads // Detection's threatAnalysis.SafeToProcess output). j.depends_on = vec![agent_id.clone(), detection_id.clone()]; + } else if custom_job_ids.iter().any(|id| id == &j.id) { + j.depends_on = if custom_reviewed_job_ids.iter().any(|id| id == &j.id) { + vec![ + agent_id.clone(), + detection_id.clone(), + manualreview_id.clone(), + ] + } else { + vec![agent_id.clone(), detection_id.clone()] + }; } else if j.id == safeoutputs_id { // The "SafeOutputs" job is the automatic path. It is gated behind // ManualReview only when it is the *sole* execution job (all tools // reviewed); in the mixed split it runs immediately after Detection // alongside the separate reviewed job. - j.depends_on = if has_review && !has_reviewed_job { + j.depends_on = if safeoutputs_waits_for_review { vec![ agent_id.clone(), detection_id.clone(), @@ -2110,6 +2750,7 @@ fn wire_explicit_dependencies(jobs: &mut [Job], prefix: &JobPrefix<'_>) -> Resul // skipped or fails. deps.push(reviewed_id.clone()); } + deps.extend(custom_job_ids.iter().cloned()); if has_teardown { deps.push(teardown_id.clone()); } @@ -2592,9 +3233,59 @@ fn prepull_images_step( steps } -fn start_safeoutputs_server_step(enabled_tools_args: &str, working_directory: &str) -> BashStep { - let script = format!( - "SAFE_OUTPUTS_PORT=8100\n\ +fn start_safeoutputs_server_step( + enabled_tools_args: &str, + working_directory: &str, + custom_tools_json: Option<&str>, +) -> BashStep { + let script = if let Some(custom_tools_json) = custom_tools_json { + format!( + "SAFE_OUTPUTS_PORT=8100\n\ + SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')\n\ + echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT\"\n\ + echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY\"\n\ + \n\ + mkdir -p \"$(Agent.TempDirectory)/staging/logs\"\n\ + cat > \"$(Agent.TempDirectory)/staging/custom-tools.json\" << 'CUSTOM_TOOLS_JSON_EOF'\n\ +{custom_tools_json}\n\ + CUSTOM_TOOLS_JSON_EOF\n\ + python3 -m json.tool \"$(Agent.TempDirectory)/staging/custom-tools.json\" > /dev/null || exit 1\n\ + \n\ + # Start SafeOutputs as HTTP server in the background\n\ + # NOTE: {enabled_tools_args} expands to either \"\" or \"--enabled-tools X ... \"\n\ + # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this.\n\ + # Positional args (output_directory, bounding_directory) MUST come after all named\n\ + # options — clap parses them positionally and reordering would break the command.\n\ + nohup /tmp/awf-tools/ado-aw mcp-http \\\n \ + --port \"$SAFE_OUTPUTS_PORT\" \\\n \ + --api-key \"$SAFE_OUTPUTS_API_KEY\" \\\n \ + --custom-tools \"$(Agent.TempDirectory)/staging/custom-tools.json\" \\\n \ + {enabled_tools_args}\"/tmp/awf-tools/staging\" \\\n \ + \"{working_directory}\" \\\n \ + > \"$(Agent.TempDirectory)/staging/logs/safeoutputs.log\" 2>&1 &\n\ + SAFE_OUTPUTS_PID=$!\n\ + echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID\"\n\ + echo \"SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)\"\n\ + \n\ + # Wait for server to be ready\n\ + READY=false\n\ + # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop\n\ + for i in $(seq 1 30); do\n \ + if curl -sf \"http://localhost:$SAFE_OUTPUTS_PORT/health\" > /dev/null 2>&1; then\n \ + echo \"SafeOutputs HTTP server is ready\"\n \ + READY=true\n \ + break\n \ + fi\n \ + sleep 1\n\ + done\n\ + if [ \"$READY\" != \"true\" ]; then\n \ + echo \"##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s\"\n \ + exit 1\n\ + fi\n" + ) + } else { + format!( + "SAFE_OUTPUTS_PORT=8100\n\ SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')\n\ echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT\"\n\ echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY\"\n\ @@ -2631,7 +3322,8 @@ fn start_safeoutputs_server_step(enabled_tools_args: &str, working_directory: &s echo \"##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s\"\n \ exit 1\n\ fi\n" - ); + ) + }; bash("Start SafeOutputs HTTP server", script) } @@ -3276,6 +3968,47 @@ fn detect_reviewed_proposals_step(working_directory: &str, reviewed: &[String]) .with_condition(Condition::Always) } +/// Scan the analyzed proposal NDJSON once and publish one output variable per +/// custom tool. Custom executor jobs use these booleans in their job-level +/// `condition:` so an empty/no-op custom proposal set does not start a job. +fn detect_custom_proposals_step(working_directory: &str, tools: &[String]) -> Result { + let mut script = format!( + "PROPOSALS=$(find \"{working_directory}/safe_outputs\" -name \"safe_outputs.ndjson\" 2>/dev/null | head -n 1)\n\ + NAMES=\"\"\n\ + RAW_SCAN=\"false\"\n\ + if [ -n \"$PROPOSALS\" ] && [ -f \"$PROPOSALS\" ]; then\n \ + if command -v jq >/dev/null 2>&1; then\n \ + if ! NAMES=$(jq -r 'select(type==\"object\") | .name // empty' \"$PROPOSALS\" 2>/dev/null); then\n \ + echo \"##vso[task.logissue type=warning]custom-proposals: jq failed to parse $PROPOSALS; using raw scan\"\n \ + RAW_SCAN=\"true\"\n \ + fi\n \ + else\n \ + RAW_SCAN=\"true\"\n \ + fi\n\ + fi\n" + ); + let mut step = bash("Detect custom proposals", ""); + for tool in tools { + let output = custom_tool_output_var(tool); + script.push_str(&format!( + "{output}=\"false\"\n\ + if [ -n \"$NAMES\" ] && printf '%s\\n' \"$NAMES\" | grep -Fxq {tool_q}; then\n \ + {output}=\"true\"\n\ + elif [ \"$RAW_SCAN\" = \"true\" ] && [ -n \"$PROPOSALS\" ] && grep -Eq '\"name\"[[:space:]]*:[[:space:]]*\"{tool}\"' \"$PROPOSALS\"; then\n \ + {output}=\"true\"\n\ + fi\n\ + echo \"##vso[task.setvariable variable={output};isOutput=true]${output}\"\n\ + echo \"{output} set to: ${output}\"\n", + tool_q = shell_quote(tool), + )); + step = step.with_output(OutputDecl::new(output)); + } + step.script = dedent(&script); + Ok(step + .with_id(StepId::new(CUSTOM_PROPOSALS_STEP_ID)?) + .with_condition(Condition::Always)) +} + fn verify_mcp_backends_step() -> BashStep { // Debug-only probe (emitted when --debug-pipeline is on). Probes every // MCPG backend via MCP initialize + tools/list to surface broken @@ -3655,6 +4388,218 @@ const _SUBMODULES_OPT_BIND: Option = None; mod tests { use super::*; + fn test_front_matter(yaml: &str) -> FrontMatter { + serde_yaml::from_str(yaml).expect("front matter should parse") + } + + fn test_ctx() -> StandaloneCtx { + let test_pool = Pool::VmImage("ubuntu-latest".to_string()); + StandaloneCtx { + pools: PerJobPools { + setup: test_pool.clone(), + agent: test_pool.clone(), + detection: test_pool.clone(), + safe_outputs: test_pool.clone(), + safe_outputs_reviewed: test_pool.clone(), + teardown: test_pool.clone(), + conclusion: test_pool.clone(), + }, + agent_display_name: "Test".to_string(), + self_checkout_fetch: CheckoutFetchOpts::default(), + working_directory: "$(Build.SourcesDirectory)".to_string(), + trigger_repo_directory: "$(Build.SourcesDirectory)".to_string(), + compiler_version: "0.0.0-test".to_string(), + engine_install_steps_yaml: String::new(), + engine_run: "echo agent".to_string(), + engine_run_detection: "echo detection".to_string(), + engine_env: "GITHUB_READ_ONLY: 1".to_string(), + engine_log_dir: "/tmp/logs".to_string(), + allowed_domains: "example.com".to_string(), + awf_mounts: "\\".to_string(), + awf_path_step_yaml: String::new(), + enabled_tools_args: String::new(), + mcpg_config_json: "{}".to_string(), + mcpg_docker_env: String::new(), + mcpg_step_env: String::new(), + source_path: "$(Build.SourcesDirectory)/agents/test.md".to_string(), + pipeline_path: "$(Build.SourcesDirectory)/agents/test.lock.yml".to_string(), + acquire_read_token: String::new(), + acquire_write_token: String::new(), + executor_ado_env: "env:\n SYSTEM_ACCESSTOKEN: $(System.AccessToken)\n".to_string(), + integrity_check_yaml: String::new(), + agent_content_value: "Test prompt".to_string(), + debug_pipeline: false, + byom_active: false, + byom_exclude_keys: Vec::new(), + detection_provider_env: Vec::new(), + } + } + + fn canonical_jobs_for(yaml: &str) -> Vec { + let fm = test_front_matter(yaml); + let cfg = test_ctx(); + build_canonical_jobs(&fm, &[], &cfg, &[], &[], &[], None).unwrap() + } + + fn job_by_id<'a>(jobs: &'a [Job], id: &str) -> &'a Job { + jobs.iter().find(|job| job.id.as_str() == id).unwrap() + } + + fn step_env_has_secret(step: &Step, name: &str, var: &str) -> bool { + let env = match step { + Step::Bash(s) => &s.env, + Step::Task(s) => &s.env, + _ => return false, + }; + matches!(env.get(name), Some(EnvValue::Secret(v)) if v == var) + } + + #[test] + fn custom_job_scripts_tool_emits_job_config_execute_secret_and_remote_checkout() { + let jobs = canonical_jobs_for( + r#" +name: Test +description: Test +safe-outputs: + scripts: + notify-team: + run: node notify.js + max: 2 + component-source: octo/tools/components/notify.md + component-sha: 0123456789012345678901234567890123456789 + manifest-digest: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + env: + API_TOKEN: NOTIFY_TOKEN +"#, + ); + let custom = job_by_id(&jobs, "Custom_notify_team"); + assert_eq!( + custom + .depends_on + .iter() + .map(|id| id.as_str()) + .collect::>(), + vec!["Agent", "Detection"] + ); + assert!(custom.steps.iter().any(|step| { + matches!( + step, + Step::Checkout(CheckoutStep { + repository: CheckoutRepo::Named(alias), + fetch_depth: Some(1), + .. + }) if alias.starts_with("import_octo_tools_") + ) + })); + assert!(custom.steps.iter().any(|step| { + matches!(step, Step::Bash(s) if s.script.contains("\"notify-team\"") && s.script.contains("\"entrypoint\": \"node notify.js\"") && s.script.contains("\"max\": 2")) + })); + assert!(custom.steps.iter().any(|step| { + matches!(step, Step::Bash(s) if s.script.contains("--custom-config")) + && step_env_has_secret(step, "API_TOKEN", "NOTIFY_TOKEN") + })); + let safeoutputs = job_by_id(&jobs, "SafeOutputs"); + assert!(safeoutputs.steps.iter().any(|step| { + matches!(step, Step::Bash(s) if s.script.contains("--exclude notify-team")) + })); + let agent = job_by_id(&jobs, "Agent"); + let detection = job_by_id(&jobs, "Detection"); + assert!(!agent.steps.iter().any(|step| step_env_has_secret( + step, + "API_TOKEN", + "NOTIFY_TOKEN" + ))); + assert!(!detection.steps.iter().any(|step| step_env_has_secret( + step, + "API_TOKEN", + "NOTIFY_TOKEN" + ))); + } + + #[test] + fn custom_job_jobs_tool_emits_pre_component_post_in_order() { + let jobs = canonical_jobs_for( + r#" +name: Test +description: Test +safe-outputs: + jobs: + deploy-thing: + steps: + - bash: echo deploy + displayName: Component deploy +"#, + ); + let custom = job_by_id(&jobs, "Custom_deploy_thing"); + let pre = custom + .steps + .iter() + .position( + |step| matches!(step, Step::Bash(s) if s.script.contains("--custom-phase pre")), + ) + .unwrap(); + let component = custom + .steps + .iter() + .position(|step| matches!(step, Step::RawYaml(yaml) if yaml.contains("Component deploy") && yaml.contains("ADO_AW_SAFE_OUTPUT_PROPOSALS"))) + .unwrap(); + let post = custom + .steps + .iter() + .position( + |step| matches!(step, Step::Bash(s) if s.script.contains("--custom-phase post")), + ) + .unwrap(); + assert!(pre < component && component < post); + } + + #[test] + fn custom_job_reviewed_tool_depends_on_manual_review() { + let jobs = canonical_jobs_for( + r#" +name: Test +description: Test +safe-outputs: + require-approval: true + scripts: + gated-tool: + run: ./gated +"#, + ); + let custom = job_by_id(&jobs, "Custom_gated_tool"); + assert_eq!( + custom + .depends_on + .iter() + .map(|id| id.as_str()) + .collect::>(), + vec!["Agent", "Detection", "ManualReview"] + ); + let safeoutputs = job_by_id(&jobs, "SafeOutputs"); + assert_eq!( + safeoutputs + .depends_on + .iter() + .map(|id| id.as_str()) + .collect::>(), + vec!["Agent", "Detection"] + ); + } + + #[test] + fn custom_job_no_custom_tools_leaves_canonical_job_names_unchanged() { + let jobs = canonical_jobs_for( + r#" +name: Test +description: Test +"#, + ); + assert_eq!( + jobs.iter().map(|job| job.id.as_str()).collect::>(), + vec!["Agent", "Detection", "SafeOutputs"] + ); + } + // ── fold_agent_conditions (issue #987) ───────────────────────────────── #[test] diff --git a/src/compile/common.rs b/src/compile/common.rs index 7737d757..ca1b11b2 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -122,6 +122,66 @@ pub struct ParsedSource { pub source_sha256: [u8; 32], } +/// Raw markdown split result used by both the typed workflow parser and +/// import resolution for component manifests that may omit typed fields. +pub(crate) struct MarkdownFrontMatterParts { + pub leading_whitespace: String, + pub yaml_raw: Option, + pub body_raw: String, + pub markdown_body: String, +} + +/// Split optional YAML front matter from a markdown document. +/// +/// When `require_front_matter` is true this preserves the historical workflow +/// parser behavior and errors unless the file starts (modulo leading +/// whitespace) with `---`. +pub(crate) fn split_markdown_front_matter( + content: &str, + require_front_matter: bool, +) -> Result { + // Allow leading whitespace before the opening fence (preserves + // historical leniency). We compute a byte offset into `content` so + // that `body_raw` extraction is purely byte-faithful, and we keep + // the whitespace prefix around so that source rewrites preserve + // anything the user (or their editor) put before the opening + // fence. + let leading_ws = content + .bytes() + .take_while(|b| b.is_ascii_whitespace()) + .count(); + let leading_whitespace = content[..leading_ws].to_string(); + let after_lead = &content[leading_ws..]; + if !after_lead.starts_with("---") { + if require_front_matter { + anyhow::bail!("Markdown file must start with YAML front matter (---)"); + } + return Ok(MarkdownFrontMatterParts { + leading_whitespace, + yaml_raw: None, + body_raw: content.to_string(), + markdown_body: content.trim().to_string(), + }); + } + + let after_open = &after_lead[3..]; + let end_idx = after_open + .find("\n---") + .context("Could not find closing --- for front matter")?; + + let yaml_raw = after_open[..end_idx].to_string(); + let body_raw_slice = &after_open[end_idx + 4..]; + let body_raw = body_raw_slice.to_string(); + let markdown_body = body_raw_slice.trim().to_string(); + + Ok(MarkdownFrontMatterParts { + leading_whitespace, + yaml_raw: Some(yaml_raw), + body_raw, + markdown_body, + }) +} + /// Parse the markdown file, run the codemod registry on the front /// matter in memory, and return both the typed `FrontMatter` and the /// raw fragments needed to rewrite the source on disk byte-faithfully. @@ -149,31 +209,11 @@ pub(crate) fn parse_markdown_detailed_with_registry( hasher.update(content.as_bytes()); let source_sha256: [u8; 32] = hasher.finalize().into(); - // Allow leading whitespace before the opening fence (preserves - // historical leniency). We compute a byte offset into `content` so - // that `body_raw` extraction is purely byte-faithful, and we keep - // the whitespace prefix around so that source rewrites preserve - // anything the user (or their editor) put before the opening - // fence. - let leading_ws = content - .bytes() - .take_while(|b| b.is_ascii_whitespace()) - .count(); - let leading_whitespace = content[..leading_ws].to_string(); - let after_lead = &content[leading_ws..]; - if !after_lead.starts_with("---") { - anyhow::bail!("Markdown file must start with YAML front matter (---)"); - } - - let after_open = &after_lead[3..]; - let end_idx = after_open - .find("\n---") - .context("Could not find closing --- for front matter")?; - - let yaml_str = &after_open[..end_idx]; - let body_raw_slice = &after_open[end_idx + 4..]; - let body_raw = body_raw_slice.to_string(); - let markdown_body = body_raw_slice.trim().to_string(); + let parts = split_markdown_front_matter(content, true)?; + let yaml_str = parts + .yaml_raw + .as_deref() + .expect("required front matter split must return YAML"); // Stage 1: parse to untyped Value, reject non-mapping at top level. let parsed_value: serde_yaml::Value = @@ -212,11 +252,11 @@ pub(crate) fn parse_markdown_detailed_with_registry( Ok(ParsedSource { front_matter, - markdown_body, + markdown_body: parts.markdown_body, codemods: report, front_matter_mapping: mapping, - leading_whitespace, - body_raw, + leading_whitespace: parts.leading_whitespace, + body_raw: parts.body_raw, source_sha256, }) } @@ -615,7 +655,44 @@ pub fn build_parameters( /// The result of lowering a `repos:` list: the ADO repository resources, the /// checkout-alias list, and per-checkout fetch tuning keyed by alias (plus /// [`SELF_CHECKOUT_ALIAS`] for the trigger repo). -pub type LoweredRepos = (Vec, Vec, HashMap); +pub type LoweredRepos = ( + Vec, + Vec, + HashMap, +); + +/// Repository resource types that are backed by an Azure DevOps **service +/// connection** and therefore require an `endpoint:` on the repository +/// resource. Azure Repos (`git`) is same-organization and needs no endpoint. +pub fn repo_type_requires_endpoint(repo_type: &str) -> bool { + matches!(repo_type, "github" | "githubenterprise" | "bitbucket") +} + +/// Validate that a repository resource of a service-connection-backed type +/// (`github` / `githubenterprise` / `bitbucket`) carries a non-empty +/// `endpoint:`. Azure Repos (`git`) resources are exempt. +/// +/// This closes a real gap: `repo_type` was previously an untested passthrough, +/// so a `type: github` resource without an `endpoint:` compiled to invalid +/// Azure DevOps YAML. Fail fast at compile time with an actionable message. +pub fn validate_repo_endpoint( + repo_type: &str, + endpoint: &Option, + name: &str, +) -> Result<()> { + let has_endpoint = endpoint + .as_deref() + .map(|e| !e.trim().is_empty()) + .unwrap_or(false); + if repo_type_requires_endpoint(repo_type) && !has_endpoint { + anyhow::bail!( + "Repository '{name}' has type '{repo_type}', which requires an `endpoint:` \ + (an Azure DevOps service connection) to authenticate. Add \ + `endpoint: ` to this repository." + ); + } + Ok(()) +} /// Lower a `repos:` list into the internal [`LoweredRepos`] triple consumed by /// the rest of the compiler. A reserved `self` entry (an entry whose *name* is @@ -647,7 +724,7 @@ pub fn lower_repos(items: &[ReposItem]) -> Result { continue; } - let (name, alias, repo_type, repo_ref, do_checkout, fetch_opts) = match item { + let (name, alias, repo_type, repo_ref, endpoint, do_checkout, fetch_opts) = match item { ReposItem::Shorthand(s) => { let (alias, name) = parse_shorthand(s)?; ( @@ -655,6 +732,7 @@ pub fn lower_repos(items: &[ReposItem]) -> Result { alias, "git".to_string(), "refs/heads/main".to_string(), + None, true, CheckoutFetchOpts::default(), ) @@ -669,6 +747,7 @@ pub fn lower_repos(items: &[ReposItem]) -> Result { alias, entry.repo_type.clone(), entry.repo_ref.clone(), + entry.endpoint.clone(), entry.checkout, CheckoutFetchOpts { fetch_depth: entry.fetch_depth, @@ -725,11 +804,14 @@ pub fn lower_repos(items: &[ReposItem]) -> Result { ); } + validate_repo_endpoint(&repo_type, &endpoint, &name)?; + repositories.push(Repository { repository: alias.clone(), repo_type, name, repo_ref, + endpoint, }); if do_checkout { @@ -1086,8 +1168,7 @@ pub(crate) fn contains_template_marker(input: &str, name: &str) -> bool { // legacy marker even if its inner content trims to `name`. Matching it // here would both raise a false positive and — in `replace_marker` — // splice `repl` after the `$`, corrupting the output. - let preceded_by_dollar = - marker_start > 0 && input.as_bytes()[marker_start - 1] == b'$'; + let preceded_by_dollar = marker_start > 0 && input.as_bytes()[marker_start - 1] == b'$'; if !preceded_by_dollar && let Some(close) = input[start..].find("}}") && input[start..start + close].trim() == name @@ -1200,7 +1281,8 @@ pub fn resolve_pool_typed( (Some(name), Some(vm_image)) => { anyhow::bail!( "pool cannot specify both `name` and `vmImage` (got name='{}', vmImage='{}')", - name, vm_image + name, + vm_image ); } (_, Some(vm_image)) => { @@ -2113,11 +2195,24 @@ pub fn validate_safe_outputs_keys(front_matter: &FrontMatter) -> Result<()> { let mut unknown: Vec<(String, Vec<&'static str>)> = Vec::new(); let mut invalid_names: Vec = Vec::new(); + // Custom tools declared under `safe-outputs.scripts` / `safe-outputs.jobs` + // (decision D16). A top-level key that matches a custom tool name is that + // tool's *configuration* (e.g. `require-approval`) and must be accepted + // rather than treated as an unknown built-in. + let custom_names: std::collections::HashSet = front_matter + .custom_safe_output_tool_names() + .into_iter() + .collect(); + for key in front_matter.safe_output_tool_names() { if !validate::is_safe_tool_name(key) { invalid_names.push(key.clone()); continue; } + if custom_names.contains(key) { + // Consumer configuration for an imported custom tool. + continue; + } if NON_MCP_SAFE_OUTPUT_KEYS.contains(&key.as_str()) { continue; } @@ -2179,6 +2274,46 @@ pub fn validate_safe_outputs_keys(front_matter: &FrontMatter) -> Result<()> { anyhow::bail!("{}", msg); } + validate_custom_safe_output_tools(front_matter)?; + + Ok(()) +} + +/// Validate custom safe-output tool definitions under `safe-outputs.scripts` +/// and `safe-outputs.jobs` (decision D16): each tool name must be a valid tool +/// name and must not collide with a built-in safe-output tool. The `scripts` +/// and `jobs` sections themselves must be mappings. +pub fn validate_custom_safe_output_tools(front_matter: &FrontMatter) -> Result<()> { + use crate::safe_outputs::ALL_KNOWN_SAFE_OUTPUTS; + + for section in ["scripts", "jobs"] { + let Some(value) = front_matter.safe_outputs.get(section) else { + continue; + }; + if value.is_null() { + continue; + } + let Some(map) = value.as_object() else { + anyhow::bail!( + "safe-outputs.{section} must be a mapping of tool-name to definition. Example:\n\n \ + safe-outputs:\n {section}:\n my-tool:\n description: ...\n" + ); + }; + for name in map.keys() { + if !validate::is_safe_tool_name(name) { + anyhow::bail!( + "safe-outputs.{section}.{name} has an invalid tool name. \ + Tool names must contain only ASCII letters, digits, and hyphens." + ); + } + if ALL_KNOWN_SAFE_OUTPUTS.contains(&name.as_str()) { + anyhow::bail!( + "safe-outputs.{section}.{name} collides with the built-in safe-output \ + tool '{name}'. Custom tool names must not shadow built-ins; rename it." + ); + } + } + } Ok(()) } @@ -2540,10 +2675,7 @@ fn add_extension_network_hosts( /// Ecosystem identifiers (e.g., `"python"`) are expanded to their domain /// lists. Raw domain names are validated against DNS-safe characters before /// insertion; an invalid name causes this function to return an error. -fn add_user_network_hosts( - user_hosts: &[String], - hosts: &mut HashSet, -) -> Result<()> { +fn add_user_network_hosts(user_hosts: &[String], hosts: &mut HashSet) -> Result<()> { for host in user_hosts { if is_ecosystem_identifier(host) { let domains = get_ecosystem_domains(host); @@ -2909,7 +3041,10 @@ mod tests { let err = resolve_pool_typed(CompileTarget::Standalone, Some(&pool)) .unwrap_err() .to_string(); - assert!(err.contains("pool.demands requires `pool.name`"), "err: {err}"); + assert!( + err.contains("pool.demands requires `pool.name`"), + "err: {err}" + ); } #[test] @@ -3024,9 +3159,8 @@ mod tests { // name-validation stage (before the duplicate check), because the raw // string is emitted verbatim into the YAML and would not match the ADO // group. - let src = - "---\nname: t\ndescription: d\nvariable-groups:\n - \" Shared Secrets \"\n---\n" - .to_string(); + let src = "---\nname: t\ndescription: d\nvariable-groups:\n - \" Shared Secrets \"\n---\n" + .to_string(); let (fm, _) = parse_markdown(&src).unwrap(); let err = validate_variable_groups(&fm).unwrap_err().to_string(); assert!(err.contains("is not a valid"), "err: {err}"); @@ -3491,6 +3625,7 @@ mod tests { repo_type: "git".to_string(), name: "org/my-repo".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, }]; let checkout = vec!["my-repo".to_string()]; let result = validate_checkout_list(&repos, &checkout); @@ -3504,6 +3639,7 @@ mod tests { repo_type: "git".to_string(), name: "org/my-repo".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, }]; let checkout = vec!["unknown-alias".to_string()]; let result = validate_checkout_list(&repos, &checkout); @@ -3518,6 +3654,7 @@ mod tests { repo_type: "git".to_string(), name: "org/my-repo".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, }]; let result = validate_checkout_list(&repos, &[]); assert!(result.is_ok()); @@ -3532,6 +3669,7 @@ mod tests { repo_type: "git".to_string(), name: "org/repo".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, }]; let checkout = vec!["repo".to_string()]; let err = validate_checkout_list(&repos, &checkout).unwrap_err(); @@ -3552,6 +3690,7 @@ mod tests { repo_type: "git".to_string(), name: "some-org/my-repo".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, }]; let checkout = vec!["my-repo".to_string()]; let err = validate_checkout_self_collision(&repos, &checkout, Some("my-repo")).unwrap_err(); @@ -3569,6 +3708,7 @@ mod tests { repo_type: "git".to_string(), name: "some-org/other".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, }]; let checkout = vec!["other".to_string()]; let result = validate_checkout_self_collision(&repos, &checkout, Some("my-repo")); @@ -3584,6 +3724,7 @@ mod tests { repo_type: "git".to_string(), name: "Some-Org/My-Repo".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, }]; let checkout = vec!["my-repo".to_string()]; let err = validate_checkout_self_collision(&repos, &checkout, Some("my-repo")).unwrap_err(); @@ -3598,6 +3739,7 @@ mod tests { repo_type: "git".to_string(), name: "org/my-repo".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, }]; let checkout = vec!["my-repo".to_string()]; let result = validate_checkout_self_collision(&repos, &checkout, None); @@ -3611,6 +3753,7 @@ mod tests { repo_type: "git".to_string(), name: "org/my-repo".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, }]; let result = validate_checkout_self_collision(&repos, &[], Some("my-repo")); assert!(result.is_ok()); @@ -4798,6 +4941,55 @@ safe-outputs: ); } + #[test] + fn test_validate_safe_outputs_keys_accepts_custom_tool_config() { + // A custom tool declared under scripts/jobs, with a top-level config key + // of the same name, must validate. + let yaml = r#"--- +name: test +description: test +safe-outputs: + scripts: + send-notification: + run: node notify.js + send-notification: + require-approval: true +--- +"#; + let (fm, _) = parse_markdown(yaml).unwrap(); + assert!(validate_safe_outputs_keys(&fm).is_ok()); + } + + #[test] + fn test_validate_custom_tool_rejects_builtin_collision() { + let yaml = r#"--- +name: test +description: test +safe-outputs: + scripts: + create-pull-request: + run: evil.js +--- +"#; + let (fm, _) = parse_markdown(yaml).unwrap(); + let err = validate_safe_outputs_keys(&fm).unwrap_err().to_string(); + assert!(err.contains("collides with the built-in"), "{err}"); + } + + #[test] + fn test_validate_custom_tool_section_must_be_mapping() { + let yaml = r#"--- +name: test +description: test +safe-outputs: + scripts: "not-a-map" +--- +"#; + let (fm, _) = parse_markdown(yaml).unwrap(); + let err = validate_safe_outputs_keys(&fm).unwrap_err().to_string(); + assert!(err.contains("must be a mapping"), "{err}"); + } + #[test] fn test_validate_safe_outputs_keys_rejects_unknown_no_close_match() { let yaml = r#"--- @@ -5204,8 +5396,8 @@ safe-outputs: #[test] fn test_model_name_rejects_single_quote() { let mut fm = minimal_front_matter(); - fm.engine = - crate::compile::types::EngineConfig::Full(Box::new(crate::compile::types::EngineOptions { + fm.engine = crate::compile::types::EngineConfig::Full(Box::new( + crate::compile::types::EngineOptions { id: Some("copilot".to_string()), model: Some("model' && echo pwned".to_string()), version: None, @@ -5217,7 +5409,8 @@ safe-outputs: timeout_minutes: None, github_app_token: None, provider: None, - })); + }, + )); let result = engine_args_for(&fm); assert!(result.is_err()); assert!( @@ -5231,8 +5424,8 @@ safe-outputs: #[test] fn test_model_name_rejects_space() { let mut fm = minimal_front_matter(); - fm.engine = - crate::compile::types::EngineConfig::Full(Box::new(crate::compile::types::EngineOptions { + fm.engine = crate::compile::types::EngineConfig::Full(Box::new( + crate::compile::types::EngineOptions { id: Some("copilot".to_string()), model: Some("model && curl evil.com".to_string()), version: None, @@ -5244,7 +5437,8 @@ safe-outputs: timeout_minutes: None, github_app_token: None, provider: None, - })); + }, + )); let result = engine_args_for(&fm); assert!(result.is_err()); } @@ -5258,8 +5452,8 @@ safe-outputs: "my_model:latest", ] { let mut fm = minimal_front_matter(); - fm.engine = - crate::compile::types::EngineConfig::Full(Box::new(crate::compile::types::EngineOptions { + fm.engine = crate::compile::types::EngineConfig::Full(Box::new( + crate::compile::types::EngineOptions { id: Some("copilot".to_string()), model: Some(name.to_string()), version: None, @@ -5271,7 +5465,8 @@ safe-outputs: timeout_minutes: None, github_app_token: None, provider: None, - })); + }, + )); let result = engine_args_for(&fm); assert!(result.is_ok(), "Model name '{}' should be valid", name); } @@ -6767,6 +6962,7 @@ safe-outputs: alias: None, repo_type: "git".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, checkout: true, fetch_depth: None, fetch_tags: None, @@ -6784,6 +6980,7 @@ safe-outputs: alias: None, repo_type: "git".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, checkout: false, fetch_depth: None, fetch_tags: None, @@ -6801,6 +6998,7 @@ safe-outputs: alias: Some("docs-v2".to_string()), repo_type: "git".to_string(), repo_ref: "refs/heads/release/2.x".to_string(), + endpoint: None, checkout: true, fetch_depth: None, fetch_tags: None, @@ -6811,6 +7009,49 @@ safe-outputs: assert_eq!(checkout, vec!["docs-v2"]); } + #[test] + fn test_repos_github_type_requires_endpoint() { + let items = vec![ReposItem::Full(RepoEntry { + name: "acme/shared".to_string(), + alias: Some("shared".to_string()), + repo_type: "github".to_string(), + repo_ref: "refs/heads/main".to_string(), + endpoint: None, + checkout: true, + fetch_depth: None, + fetch_tags: None, + })]; + let err = lower_repos(&items).unwrap_err(); + assert!(err.to_string().contains("requires an `endpoint:`"), "{err}"); + assert!(err.to_string().contains("acme/shared"), "{err}"); + } + + #[test] + fn test_repos_github_type_with_endpoint_ok() { + let items = vec![ReposItem::Full(RepoEntry { + name: "acme/shared".to_string(), + alias: Some("shared".to_string()), + repo_type: "githubenterprise".to_string(), + repo_ref: "refs/heads/main".to_string(), + endpoint: Some("shared-conn".to_string()), + checkout: true, + fetch_depth: None, + fetch_tags: None, + })]; + let (repos, _checkout, _fetch) = lower_repos(&items).unwrap(); + assert_eq!(repos[0].repo_type, "githubenterprise"); + assert_eq!(repos[0].endpoint.as_deref(), Some("shared-conn")); + } + + #[test] + fn test_repos_git_type_needs_no_endpoint() { + // Same-org Azure Repos (`git`) must NOT require an endpoint. + assert!(validate_repo_endpoint("git", &None, "proj/repo").is_ok()); + assert!(repo_type_requires_endpoint("github")); + assert!(repo_type_requires_endpoint("githubenterprise")); + assert!(!repo_type_requires_endpoint("git")); + } + #[test] fn test_repos_rejects_duplicate_aliases() { let items = vec![ @@ -6846,6 +7087,7 @@ safe-outputs: alias: Some("root".to_string()), repo_type: "git".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, checkout: true, fetch_depth: None, fetch_tags: None, @@ -6865,6 +7107,7 @@ safe-outputs: alias: Some(bad.to_string()), repo_type: "git".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, checkout: true, fetch_depth: None, fetch_tags: None, @@ -6882,6 +7125,7 @@ safe-outputs: alias: Some("my-tools_2".to_string()), repo_type: "git".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, checkout: true, fetch_depth: None, fetch_tags: None, @@ -6899,6 +7143,7 @@ safe-outputs: alias: None, repo_type: "git".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, checkout: false, fetch_depth: None, fetch_tags: None, @@ -6918,6 +7163,7 @@ safe-outputs: alias: None, repo_type: "git".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, checkout: true, fetch_depth: Some(1), fetch_tags: Some(false), @@ -6943,6 +7189,7 @@ safe-outputs: alias: None, repo_type: "git".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, checkout: true, fetch_depth: Some(0), fetch_tags: Some(false), @@ -6965,6 +7212,7 @@ safe-outputs: alias: None, repo_type: "git".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, checkout: true, fetch_depth: Some(1), fetch_tags: None, @@ -6974,6 +7222,7 @@ safe-outputs: alias: None, repo_type: "git".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, checkout: true, fetch_depth: None, fetch_tags: Some(true), @@ -6992,6 +7241,7 @@ safe-outputs: alias: Some("mine".to_string()), repo_type: "git".to_string(), repo_ref: "refs/heads/feature".to_string(), + endpoint: None, checkout: false, fetch_depth: Some(1), fetch_tags: None, @@ -7028,6 +7278,7 @@ safe-outputs: alias: None, repo_type: "git".to_string(), repo_ref: "refs/heads/main".to_string(), + endpoint: None, checkout: false, fetch_depth: Some(1), fetch_tags: Some(false), diff --git a/src/compile/custom_tools.rs b/src/compile/custom_tools.rs new file mode 100644 index 00000000..ec7f704b --- /dev/null +++ b/src/compile/custom_tools.rs @@ -0,0 +1,437 @@ +//! Compile-time schema generation for config-driven custom safe-output tools. + +use std::collections::HashSet; + +use anyhow::{Context, Result, anyhow, bail, ensure}; +use serde::Serialize; +use serde_json::{Map, Value, json}; + +use crate::compile::types::FrontMatter; + +const CUSTOM_TOOL_LIMIT: usize = 10; +const DEFAULT_STRING_MAX_LENGTH: u64 = 4_000; +const HARD_STRING_MAX_LENGTH: u64 = 8_000; + +/// A compiler-generated custom MCP tool definition. +#[derive(Debug, Clone, PartialEq)] +pub struct CustomToolSchema { + pub name: String, + pub description: String, + pub input_schema: Map, +} + +/// Generate closed JSON Schemas for custom tools under +/// `safe-outputs.scripts` and `safe-outputs.jobs`. +pub fn generate_custom_tool_schemas(front_matter: &FrontMatter) -> Result> { + let mut schemas = Vec::new(); + let mut seen = HashSet::new(); + + for section in ["scripts", "jobs"] { + let Some(section_value) = front_matter.safe_outputs.get(section) else { + continue; + }; + let section_obj = section_value.as_object().ok_or_else(|| { + anyhow!("safe-outputs.{section} must be a mapping of tool name to tool definition") + })?; + + for (tool_name, tool_def) in section_obj { + validate_tool_name(section, tool_name)?; + ensure!( + seen.insert(tool_name.clone()), + "custom safe-output tool '{tool_name}' is declared more than once" + ); + + let tool_obj = tool_def.as_object().ok_or_else(|| { + anyhow!( + "safe-outputs.{section}.{tool_name} must be a mapping with optional \ + description/max/executor fields and inputs" + ) + })?; + let description = optional_string(tool_obj, "description") + .with_context(|| format!("safe-outputs.{section}.{tool_name}.description"))? + .unwrap_or_default(); + let input_schema = build_input_schema(section, tool_name, tool_obj.get("inputs"))?; + + schemas.push(CustomToolSchema { + name: tool_name.clone(), + description, + input_schema, + }); + ensure!( + schemas.len() <= CUSTOM_TOOL_LIMIT, + "custom safe-output tools per workflow must be <= {CUSTOM_TOOL_LIMIT}" + ); + } + } + + Ok(schemas) +} + +/// Serialize schemas to the JSON array shape consumed by the SafeOutputs MCP +/// server's `--custom-tools` loader: +/// `[{ "name": ..., "description": ..., "inputSchema": ... }]`. +pub fn custom_tools_json(schemas: &[CustomToolSchema]) -> Result { + #[derive(Serialize)] + struct CustomToolDef<'a> { + name: &'a str, + description: &'a str, + #[serde(rename = "inputSchema")] + input_schema: &'a Map, + } + + let defs: Vec<_> = schemas + .iter() + .map(|schema| CustomToolDef { + name: &schema.name, + description: &schema.description, + input_schema: &schema.input_schema, + }) + .collect(); + + serde_json::to_string(&defs).context("failed to serialize custom tool schemas") +} + +fn validate_tool_name(section: &str, tool_name: &str) -> Result<()> { + ensure!( + crate::validate::is_safe_tool_name(tool_name), + "safe-outputs.{section}.{tool_name}: invalid custom tool name \ + (must be ASCII alphanumeric/hyphens only)" + ); + ensure!( + !crate::safe_outputs::ALL_KNOWN_SAFE_OUTPUTS.contains(&tool_name), + "safe-outputs.{section}.{tool_name}: custom tool name collides with a built-in \ + safe-output tool" + ); + Ok(()) +} + +fn validate_input_name(section: &str, tool_name: &str, input_name: &str) -> Result<()> { + ensure!( + crate::validate::is_valid_parameter_name(input_name), + "safe-outputs.{section}.{tool_name}.inputs.{input_name}: invalid input name \ + (must match [A-Za-z_][A-Za-z0-9_]*)" + ); + Ok(()) +} + +fn build_input_schema( + section: &str, + tool_name: &str, + inputs: Option<&Value>, +) -> Result> { + let mut schema = Map::new(); + schema.insert("type".to_string(), Value::String("object".to_string())); + schema.insert("additionalProperties".to_string(), Value::Bool(false)); + + let mut required = Vec::new(); + let mut properties = Map::new(); + + if let Some(inputs_value) = inputs { + let inputs_obj = inputs_value.as_object().ok_or_else(|| { + anyhow!("safe-outputs.{section}.{tool_name}.inputs must be a mapping") + })?; + + for (input_name, input_def) in inputs_obj { + validate_input_name(section, tool_name, input_name)?; + let input_obj = input_def.as_object().ok_or_else(|| { + anyhow!("safe-outputs.{section}.{tool_name}.inputs.{input_name} must be a mapping") + })?; + + if required_flag(input_obj, section, tool_name, input_name)? { + required.push(Value::String(input_name.clone())); + } + properties.insert( + input_name.clone(), + scalar_schema(section, tool_name, input_name, input_obj)?, + ); + } + } + + schema.insert("required".to_string(), Value::Array(required)); + schema.insert("properties".to_string(), Value::Object(properties)); + Ok(schema) +} + +fn scalar_schema( + section: &str, + tool_name: &str, + input_name: &str, + input_obj: &Map, +) -> Result { + let input_type = input_obj + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| { + anyhow!("safe-outputs.{section}.{tool_name}.inputs.{input_name}.type is required") + })?; + + match input_type { + "string" => Ok(json!({ + "type": "string", + "maxLength": string_max_length(input_obj, section, tool_name, input_name)?, + })), + "number" => Ok(json!({ "type": "number" })), + "boolean" => Ok(json!({ "type": "boolean" })), + "choice" => Ok(json!({ + "type": "string", + "enum": choice_options(input_obj, section, tool_name, input_name)?, + })), + "array" | "object" => bail!( + "safe-outputs.{section}.{tool_name}.inputs.{input_name}: agent-facing \ + custom tool inputs are scalar-only; type '{input_type}' is not supported \ + (use string, number, boolean, or choice)" + ), + other => bail!( + "safe-outputs.{section}.{tool_name}.inputs.{input_name}: unknown input type \ + '{other}' (expected string, number, boolean, or choice)" + ), + } +} + +fn string_max_length( + input_obj: &Map, + section: &str, + tool_name: &str, + input_name: &str, +) -> Result { + let Some(value) = input_obj.get("max-length") else { + return Ok(DEFAULT_STRING_MAX_LENGTH); + }; + let max = value.as_u64().ok_or_else(|| { + anyhow!( + "safe-outputs.{section}.{tool_name}.inputs.{input_name}.max-length must be \ + a positive integer" + ) + })?; + ensure!( + max > 0, + "safe-outputs.{section}.{tool_name}.inputs.{input_name}.max-length must be > 0" + ); + ensure!( + max <= HARD_STRING_MAX_LENGTH, + "safe-outputs.{section}.{tool_name}.inputs.{input_name}.max-length must be <= \ + {HARD_STRING_MAX_LENGTH}" + ); + Ok(max) +} + +fn choice_options( + input_obj: &Map, + section: &str, + tool_name: &str, + input_name: &str, +) -> Result> { + let options = input_obj.get("options").ok_or_else(|| { + anyhow!("safe-outputs.{section}.{tool_name}.inputs.{input_name}.options is required") + })?; + let options = options.as_array().ok_or_else(|| { + anyhow!("safe-outputs.{section}.{tool_name}.inputs.{input_name}.options must be a list") + })?; + ensure!( + !options.is_empty(), + "safe-outputs.{section}.{tool_name}.inputs.{input_name}.options must not be empty" + ); + + options + .iter() + .map(|option| { + option.as_str().map(str::to_string).ok_or_else(|| { + anyhow!( + "safe-outputs.{section}.{tool_name}.inputs.{input_name}.options entries \ + must be strings" + ) + }) + }) + .collect() +} + +fn required_flag( + input_obj: &Map, + section: &str, + tool_name: &str, + input_name: &str, +) -> Result { + match input_obj.get("required") { + None => Ok(false), + Some(Value::Bool(required)) => Ok(*required), + Some(_) => { + bail!("safe-outputs.{section}.{tool_name}.inputs.{input_name}.required must be boolean") + } + } +} + +fn optional_string(obj: &Map, key: &str) -> Result> { + match obj.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s.clone())), + Some(_) => bail!("must be a string when present"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + fn parse_front_matter(yaml: &str) -> FrontMatter { + serde_yaml::from_str(yaml).unwrap() + } + + fn schema_by_name<'a>(schemas: &'a [CustomToolSchema], name: &str) -> &'a CustomToolSchema { + schemas.iter().find(|schema| schema.name == name).unwrap() + } + + #[test] + fn scripts_and_jobs_generate_closed_scalar_schemas() { + let fm = parse_front_matter( + r#" +name: Test +description: Test +safe-outputs: + scripts: + send-notification: + description: Send a structured notification. + max: 3 + run: node notify.js + inputs: + title: { type: string, required: true, max-length: 120 } + severity: { type: choice, options: [info, warning, critical], required: true } + jobs: + deploy-thing: + description: Deploy via ADO steps. + steps: [] + inputs: + target: { type: string, required: true } +"#, + ); + + let schemas = generate_custom_tool_schemas(&fm).unwrap(); + assert_eq!(schemas.len(), 2); + + let send = schema_by_name(&schemas, "send-notification"); + assert_eq!(send.description, "Send a structured notification."); + assert_eq!(send.input_schema["type"], "object"); + assert_eq!(send.input_schema["additionalProperties"], false); + assert_eq!( + send.input_schema["properties"]["title"], + json!({ "type": "string", "maxLength": 120 }) + ); + assert_eq!( + send.input_schema["properties"]["severity"], + json!({ "type": "string", "enum": ["info", "warning", "critical"] }) + ); + let required = send.input_schema["required"].as_array().unwrap(); + assert!(required.contains(&Value::String("title".to_string()))); + assert!(required.contains(&Value::String("severity".to_string()))); + + let deploy = schema_by_name(&schemas, "deploy-thing"); + assert_eq!( + deploy.input_schema["properties"]["target"], + json!({ "type": "string", "maxLength": DEFAULT_STRING_MAX_LENGTH }) + ); + } + + #[test] + fn array_and_object_agent_inputs_are_rejected() { + for input_type in ["array", "object"] { + let fm = parse_front_matter(&format!( + r#" +name: Test +description: Test +safe-outputs: + scripts: + bad-tool: + run: ./tool + inputs: + payload: {{ type: {input_type} }} +"# + )); + + let err = generate_custom_tool_schemas(&fm).unwrap_err(); + assert!(err.to_string().contains("scalar-only")); + } + } + + #[test] + fn built_in_tool_name_collisions_are_rejected() { + let fm = parse_front_matter( + r#" +name: Test +description: Test +safe-outputs: + scripts: + create-work-item: + run: ./tool + inputs: {} +"#, + ); + + let err = generate_custom_tool_schemas(&fm).unwrap_err(); + assert!(err.to_string().contains("collides with a built-in")); + } + + #[test] + fn more_than_ten_custom_tools_is_rejected() { + let mut yaml = String::from("name: Test\ndescription: Test\nsafe-outputs:\n scripts:\n"); + for i in 0..11 { + yaml.push_str(&format!( + " tool-{i}:\n run: ./tool\n inputs: {{}}\n" + )); + } + let fm = parse_front_matter(&yaml); + + let err = generate_custom_tool_schemas(&fm).unwrap_err(); + assert!( + err.to_string() + .contains("custom safe-output tools per workflow") + ); + } + + #[test] + fn custom_tools_json_uses_camel_case_input_schema() { + #[derive(Deserialize)] + struct MirrorCustomToolDef { + name: String, + description: String, + #[serde(rename = "inputSchema")] + input_schema: Map, + } + + let fm = parse_front_matter( + r#" +name: Test +description: Test +safe-outputs: + scripts: + send-notification: + description: Send a structured notification. + run: node notify.js + inputs: + title: { type: string, required: true } +"#, + ); + let schemas = generate_custom_tool_schemas(&fm).unwrap(); + let json = custom_tools_json(&schemas).unwrap(); + + assert!(json.contains("\"inputSchema\"")); + assert!(!json.contains("input_schema")); + let defs: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(defs.len(), 1); + assert_eq!(defs[0].name, "send-notification"); + assert_eq!(defs[0].description, "Send a structured notification."); + assert_eq!(defs[0].input_schema["additionalProperties"], false); + } + + #[test] + fn no_scripts_or_jobs_returns_empty_vec() { + let fm = parse_front_matter( + r#" +name: Test +description: Test +"#, + ); + + let schemas = generate_custom_tool_schemas(&fm).unwrap(); + assert!(schemas.is_empty()); + } +} diff --git a/src/compile/extensions/ado_aw_marker.rs b/src/compile/extensions/ado_aw_marker.rs index 55b8fecc..67f6c1e5 100644 --- a/src/compile/extensions/ado_aw_marker.rs +++ b/src/compile/extensions/ado_aw_marker.rs @@ -25,6 +25,7 @@ use super::{CompileContext, CompilerExtension, Declarations, ExtensionPhase}; use crate::compile::ir::condition::Condition; use crate::compile::ir::step::{BashStep, Step}; +use serde::Serialize; // ─── ado-aw marker (always-on, internal) ───────────────────────────── @@ -36,7 +37,31 @@ use crate::compile::ir::step::{BashStep, Step}; /// project-scope discovery in [`crate::ado`]. Discovery enumerates ADO /// definitions, expands each via the Pipeline Preview API, and greps /// the result for this marker. -pub struct AdoAwMarkerExtension; +#[derive(Debug, Clone, Default)] +pub struct AdoAwMarkerExtension { + custom_components: Vec, +} + +/// Provenance for a safe-output custom component imported at compile time. +/// +/// The later import-resolution plumbing is responsible for computing the +/// digest strings with [`crate::hash::sha256_hex`]. The marker extension only +/// carries and emits the resolved values. +#[derive(Debug, Clone, Serialize)] +pub struct CustomComponentProvenance { + /// Import source, for example `org/repo/path`. + pub source: String, + /// Full 40-character commit SHA that the component resolved to. + pub sha: String, + pub manifest_digest: String, + pub schema_digest: String, +} + +impl AdoAwMarkerExtension { + pub fn new(custom_components: Vec) -> Self { + Self { custom_components } + } +} impl CompilerExtension for AdoAwMarkerExtension { fn name(&self) -> &str { @@ -54,7 +79,7 @@ impl CompilerExtension for AdoAwMarkerExtension { /// Returns the two Agent-job prepare steps as typed /// `Step::Bash(BashStep)` values. fn declarations(&self, ctx: &CompileContext) -> anyhow::Result { - let Some(metadata) = CompileMetadata::from_ctx(ctx) else { + let Some(metadata) = CompileMetadata::from_ctx(ctx, self.custom_components.clone()) else { return Ok(Declarations::default()); }; let agent_prepare_steps = vec![ @@ -116,10 +141,14 @@ struct CompileMetadata { engine: String, model: String, agent_name: String, + custom_components: Vec, } impl CompileMetadata { - fn from_ctx(ctx: &CompileContext) -> Option { + fn from_ctx( + ctx: &CompileContext, + custom_components: Vec, + ) -> Option { let input_path = ctx.input_path?; Some(Self { source: super::super::common::normalize_source_path(input_path), @@ -144,23 +173,24 @@ impl CompileMetadata { .to_string(), }, agent_name: ctx.agent_name.to_string(), + custom_components, }) } fn marker_json(&self) -> String { - serde_json::to_string(&serde_json::json!({ + let value = self.with_custom_components(serde_json::json!({ "schema": 1, "source": &self.source, "org": &self.org, "repo": &self.repo, "version": &self.compiler_version, "target": &self.target, - })) - .unwrap() + })); + serde_json::to_string(&value).unwrap() } fn aw_info_json(&self) -> String { - serde_json::to_string(&serde_json::json!({ + let value = self.with_custom_components(serde_json::json!({ "schema": "ado-aw/aw_info/1", "source": &self.source, "org": &self.org, @@ -174,8 +204,18 @@ impl CompileMetadata { "source_version": "$(Build.SourceVersion)", "source_branch": "$(Build.SourceBranch)", "build_definition_id": "$(System.DefinitionId)", - })) - .unwrap() + })); + serde_json::to_string(&value).unwrap() + } + + fn with_custom_components(&self, mut value: serde_json::Value) -> serde_json::Value { + if !self.custom_components.is_empty() { + value.as_object_mut().unwrap().insert( + "custom_components".to_string(), + serde_json::to_value(&self.custom_components).unwrap(), + ); + } + value } } @@ -198,7 +238,7 @@ mod tests { } fn agent_prepare_steps(ctx: &CompileContext<'_>) -> Vec { - AdoAwMarkerExtension + AdoAwMarkerExtension::default() .declarations(ctx) .unwrap() .agent_prepare_steps @@ -354,6 +394,90 @@ mod tests { ); } + #[test] + fn default_marker_and_aw_info_omit_custom_components() { + let fm = parse_fm("name: t\ndescription: x\n"); + let input_path = Path::new("agents/foo.md"); + let ctx = CompileContext { + agent_name: &fm.name, + front_matter: &fm, + ado_context: None, + engine: crate::engine::Engine::Copilot, + compile_dir: None, + input_path: Some(input_path), + }; + let steps = agent_prepare_steps(&ctx); + assert_eq!(steps.len(), 2); + for step in steps.iter().map(bash_step) { + assert!( + !step.script.contains("\"custom_components\""), + "default marker extension must omit custom_components:\n{}", + step.script + ); + } + } + + #[test] + fn emits_custom_component_provenance_when_configured() { + let fm = parse_fm("name: t\ndescription: x\n"); + let input_path = Path::new("agents/foo.md"); + let ctx = CompileContext { + agent_name: &fm.name, + front_matter: &fm, + ado_context: None, + engine: crate::engine::Engine::Copilot, + compile_dir: None, + input_path: Some(input_path), + }; + let component = CustomComponentProvenance { + source: "org/repo/components/create-pr".to_string(), + sha: "0123456789abcdef0123456789abcdef01234567".to_string(), + manifest_digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + schema_digest: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_string(), + }; + let steps = AdoAwMarkerExtension::new(vec![component]) + .declarations(&ctx) + .unwrap() + .agent_prepare_steps; + assert_eq!(steps.len(), 2); + + for step in steps.iter().map(bash_step) { + assert!( + step.script.contains("\"custom_components\":["), + "step missing custom_components array:\n{}", + step.script + ); + assert!( + step.script + .contains("\"source\":\"org/repo/components/create-pr\""), + "step missing component source:\n{}", + step.script + ); + assert!( + step.script + .contains("\"sha\":\"0123456789abcdef0123456789abcdef01234567\""), + "step missing component sha:\n{}", + step.script + ); + assert!( + step.script.contains( + "\"manifest_digest\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"" + ), + "step missing component manifest digest:\n{}", + step.script + ); + assert!( + step.script.contains( + "\"schema_digest\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"" + ), + "step missing component schema digest:\n{}", + step.script + ); + } + } + #[test] fn org_and_repo_embed_from_ado_context_lowercased() { // When the compiler runs inside an ADO checkout (the production @@ -454,7 +578,7 @@ mod tests { compile_dir: None, input_path: Some(input_path), }; - let decl = AdoAwMarkerExtension.declarations(&ctx).unwrap(); + let decl = AdoAwMarkerExtension::default().declarations(&ctx).unwrap(); assert_eq!(decl.agent_prepare_steps.len(), 2); match (&decl.agent_prepare_steps[0], &decl.agent_prepare_steps[1]) { (Step::Bash(marker), Step::Bash(aw_info)) => { diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 378b87c4..111b63b5 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -573,7 +573,7 @@ pub use crate::runtimes::node::NodeExtension; pub use crate::runtimes::python::PythonExtension; pub use crate::tools::azure_devops::AzureDevOpsExtension; pub use crate::tools::cache_memory::CacheMemoryExtension; -pub use ado_aw_marker::AdoAwMarkerExtension; +pub use ado_aw_marker::{AdoAwMarkerExtension, CustomComponentProvenance}; pub use ado_script::AdoScriptExtension; pub use azure_cli::AzureCliExtension; pub use exec_context::{ @@ -635,7 +635,9 @@ pub fn collect_extensions(front_matter: &FrontMatter) -> Vec { // `NodeExtension`). The user's pinned Node version then "wins last" // on PATH for the rest of the Agent job. let mut extensions = vec![ - Extension::AdoAwMarker(AdoAwMarkerExtension), + Extension::AdoAwMarker(AdoAwMarkerExtension::new( + Vec::::new(), + )), Extension::GitHub(GitHubExtension), Extension::SafeOutputs(SafeOutputsExtension), Extension::AdoScript(Box::new({ diff --git a/src/compile/imports/alias.rs b/src/compile/imports/alias.rs new file mode 100644 index 00000000..fc45ec6e --- /dev/null +++ b/src/compile/imports/alias.rs @@ -0,0 +1,381 @@ +//! Synthesis of compiler-internal repository-resource aliases from resolved +//! remote imports (for the runtime executor-job checkout). +//! +//! The compiler owns these aliases: authors write only `imports:` entries, and +//! this pass derives the ADO `resources.repositories` entries needed by later +//! executor jobs. Aliases are stable, valid ADO identifiers, and readable: +//! `import___`. The readable parts replace +//! non-ASCII-identifier characters with `_`; the fixed hash suffix is derived +//! from the original `owner/repo`, so repos that collide under simple +//! sanitization (for example `a-b/c` and `a_b/c`) still get distinct aliases +//! without requiring global import-set context. +//! +//! Repository resources are deduplicated by `(owner, repo, endpoint)`, not by +//! manifest path or SHA: ADO repository-resource refs are branch/tag-level +//! authorization handles, while the executor job will pin the exact commit with +//! `git checkout ` after checkout. +#![allow(dead_code)] + +use std::collections::{HashMap, HashSet}; + +use anyhow::Result; + +use super::ResolvedImport; +use crate::compile::types::{CompileTarget, ImportSource, Repository}; +use crate::hash::sha256_hex; + +const REPOSITORY_RESOURCE_REF: &str = "refs/heads/main"; +const HASH_SUFFIX_LEN: usize = 12; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct RepoKey { + owner: String, + repo: String, + endpoint: Option, +} + +/// Return the stable compiler-generated repository-resource alias for a remote +/// import source. +/// +/// The alias always starts with `import_`, contains only ASCII alphanumeric +/// characters and underscores, and includes a short SHA-256 suffix of the +/// original `owner/repo` to avoid collisions from sanitization alone. +pub fn alias_identifier(owner: &str, repo: &str) -> String { + let digest = short_hash(&format!("{owner}/{repo}")); + let owner = sanitize_identifier_part(owner); + let repo = sanitize_identifier_part(repo); + format!("import_{owner}_{repo}_{digest}") +} + +/// Synthesize ADO repository resources for remote imports. +/// +/// Local imports are compile-time only and do not need a runtime checkout, so +/// they are skipped. Remote imports from the same `(owner, repo, endpoint)` are +/// collapsed to one resource even when they point at different manifest paths or +/// SHAs; the resource `ref` is only an ADO authorization ref, not the execution +/// pin. +pub fn synthesize_repo_aliases(imports: &[ResolvedImport]) -> Result> { + let mut ordered_keys = Vec::new(); + let mut seen = HashSet::new(); + + for import in imports { + let ImportSource::Remote(spec) = &import.source else { + continue; + }; + + let key = RepoKey { + owner: spec.owner.clone(), + repo: spec.repo.clone(), + endpoint: import.entry.endpoint.clone(), + }; + + if seen.insert(key.clone()) { + ordered_keys.push(key); + } + } + + let mut alias_counts: HashMap = HashMap::new(); + for key in &ordered_keys { + *alias_counts + .entry(alias_identifier(&key.owner, &key.repo)) + .or_default() += 1; + } + + Ok(ordered_keys + .into_iter() + .map(|key| { + let base_alias = alias_identifier(&key.owner, &key.repo); + let alias = if alias_counts.get(&base_alias).copied().unwrap_or(0) > 1 { + format!( + "{}_{}", + base_alias, + short_hash(&format!( + "{}/{}/{}", + key.owner, + key.repo, + key.endpoint.as_deref().unwrap_or("") + )) + ) + } else { + base_alias + }; + + Repository { + repository: alias, + // MVP inference: imports with an explicit endpoint are backed + // by a GitHub/GHE service connection; endpoint-less imports are + // same-org Azure Repos checkouts using System.AccessToken. + repo_type: if key.endpoint.is_some() { + "github".to_string() + } else { + "git".to_string() + }, + name: format!("{}/{}", key.owner, key.repo), + // NOTE: ADO repository-resource `ref` does not accept commit + // SHAs. This branch ref is for resource authorization only; + // the executor checkout must pin the exact import SHA at + // runtime with `git checkout `. + repo_ref: REPOSITORY_RESOURCE_REF.to_string(), + endpoint: key.endpoint, + } + }) + .collect()) +} + +fn sanitize_identifier_part(value: &str) -> String { + let sanitized: String = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '_' { + ch + } else { + '_' + } + }) + .collect(); + + if sanitized.is_empty() { + "_".to_string() + } else { + sanitized + } +} + +fn short_hash(value: &str) -> String { + sha256_hex(value.as_bytes())[..HASH_SUFFIX_LEN].to_string() +} + +/// Diagnostic (P7): job/stage compile targets are *templates* and cannot emit +/// top-level `resources.repositories`, so the **parent** pipeline must declare +/// and authorize the compiler-generated import repository aliases. Returns a +/// human-readable message listing the alias identifiers the parent must +/// declare, or `None` when the target owns its resources (standalone / 1es) or +/// there are no import aliases. +/// +/// The compiler must NOT broaden access automatically — this surfaces the +/// requirement to the pipeline administrator instead. +pub fn import_resource_parent_diagnostic( + target: CompileTarget, + aliases: &[String], +) -> Option { + if aliases.is_empty() { + return None; + } + match target { + CompileTarget::Job | CompileTarget::Stage => { + let target_name = match target { + CompileTarget::Job => "job", + CompileTarget::Stage => "stage", + _ => unreachable!(), + }; + Some(format!( + "target '{target_name}' is an Azure DevOps template and cannot declare \ + top-level repository resources; the parent pipeline must define and \ + authorize these imported component repositories: {}", + aliases.join(", ") + )) + } + CompileTarget::Standalone | CompileTarget::OneES => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile::imports::ImportProvenance; + use crate::compile::types::{ImportEntry, ParsedImportSpec}; + use crate::secure::CommitSha; + + fn remote_import( + owner: &str, + repo: &str, + path: &str, + sha: &str, + endpoint: Option<&str>, + ) -> ResolvedImport { + ResolvedImport { + entry: ImportEntry { + uses: format!("{owner}/{repo}/{path}@{sha}"), + with: serde_json::Map::new(), + endpoint: endpoint.map(str::to_string), + }, + source: ImportSource::Remote(ParsedImportSpec { + owner: owner.to_string(), + repo: repo.to_string(), + path: path.to_string(), + sha: CommitSha::parse(sha).expect("test sha should be valid"), + section: None, + optional: false, + }), + front_matter: serde_yaml::Value::Null, + body: String::new(), + provenance: ImportProvenance { + source: format!("{owner}/{repo}/{path}"), + sha: Some(sha.to_string()), + manifest_digest: "digest".to_string(), + }, + } + } + + fn local_import(path: &str) -> ResolvedImport { + ResolvedImport { + entry: ImportEntry { + uses: path.to_string(), + with: serde_json::Map::new(), + endpoint: None, + }, + source: ImportSource::Local { + path: path.to_string(), + section: None, + optional: false, + }, + front_matter: serde_yaml::Value::Null, + body: String::new(), + provenance: ImportProvenance { + source: path.to_string(), + sha: None, + manifest_digest: "digest".to_string(), + }, + } + } + + fn assert_valid_alias(alias: &str) { + assert!(!alias.is_empty()); + assert!(!alias.as_bytes()[0].is_ascii_digit()); + assert!( + alias + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_'), + "invalid alias: {alias}" + ); + } + + #[test] + fn single_remote_import_with_endpoint_synthesizes_github_resource() { + let imports = vec![remote_import( + "owner", + "repo", + "component.md", + "0123456789abcdef0123456789abcdef01234567", + Some("github-service-connection"), + )]; + + let repos = synthesize_repo_aliases(&imports).expect("synthesis should succeed"); + + assert_eq!(repos.len(), 1); + let repo = &repos[0]; + assert_eq!(repo.repo_type, "github"); + assert_eq!(repo.endpoint.as_deref(), Some("github-service-connection")); + assert_eq!(repo.name, "owner/repo"); + assert_eq!(repo.repo_ref, "refs/heads/main"); + assert_valid_alias(&repo.repository); + } + + #[test] + fn remote_import_without_endpoint_synthesizes_git_resource() { + let imports = vec![remote_import( + "ado", + "repo", + "component.md", + "1123456789abcdef0123456789abcdef01234567", + None, + )]; + + let repos = synthesize_repo_aliases(&imports).expect("synthesis should succeed"); + + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].repo_type, "git"); + assert_eq!(repos[0].endpoint, None); + assert_eq!(repos[0].name, "ado/repo"); + } + + #[test] + fn same_repo_at_different_paths_and_shas_is_deduplicated() { + let imports = vec![ + remote_import( + "owner", + "repo", + "one.md", + "2123456789abcdef0123456789abcdef01234567", + Some("endpoint"), + ), + remote_import( + "owner", + "repo", + "two.md", + "3123456789abcdef0123456789abcdef01234567", + Some("endpoint"), + ), + ]; + + let repos = synthesize_repo_aliases(&imports).expect("synthesis should succeed"); + + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].name, "owner/repo"); + assert_eq!(repos[0].endpoint.as_deref(), Some("endpoint")); + } + + #[test] + fn different_repos_with_naive_alias_collision_get_distinct_aliases() { + let imports = vec![ + remote_import( + "a-b", + "component", + "tool.md", + "4123456789abcdef0123456789abcdef01234567", + None, + ), + remote_import( + "a_b", + "component", + "tool.md", + "5123456789abcdef0123456789abcdef01234567", + None, + ), + ]; + + let repos = synthesize_repo_aliases(&imports).expect("synthesis should succeed"); + + assert_eq!(repos.len(), 2); + assert_ne!(repos[0].repository, repos[1].repository); + assert_valid_alias(&repos[0].repository); + assert_valid_alias(&repos[1].repository); + } + + #[test] + fn local_imports_are_skipped() { + let imports = vec![local_import("components/local.md")]; + + let repos = synthesize_repo_aliases(&imports).expect("synthesis should succeed"); + + assert!(repos.is_empty()); + } + + #[test] + fn alias_identifier_is_stable_and_valid() { + let alias = alias_identifier("123-owner.with-dots", "repo/name"); + + assert_eq!(alias, alias_identifier("123-owner.with-dots", "repo/name")); + assert_valid_alias(&alias); + assert!(alias.starts_with("import_")); + } + + #[test] + fn parent_diagnostic_emitted_for_template_targets() { + let aliases = vec!["import_owner_repo_abc".to_string()]; + let job = import_resource_parent_diagnostic(CompileTarget::Job, &aliases) + .expect("job target should require a parent diagnostic"); + assert!(job.contains("import_owner_repo_abc")); + assert!(job.contains("parent pipeline")); + assert!(import_resource_parent_diagnostic(CompileTarget::Stage, &aliases).is_some()); + } + + #[test] + fn parent_diagnostic_absent_for_owning_targets_or_no_aliases() { + let aliases = vec!["import_owner_repo_abc".to_string()]; + assert!(import_resource_parent_diagnostic(CompileTarget::Standalone, &aliases).is_none()); + assert!(import_resource_parent_diagnostic(CompileTarget::OneES, &aliases).is_none()); + // No aliases → no diagnostic even for template targets. + assert!(import_resource_parent_diagnostic(CompileTarget::Job, &[]).is_none()); + } +} diff --git a/src/compile/imports/integration_tests.rs b/src/compile/imports/integration_tests.rs new file mode 100644 index 00000000..6f50100c --- /dev/null +++ b/src/compile/imports/integration_tests.rs @@ -0,0 +1,353 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use serde_yaml::{Mapping, Value}; + +use super::alias::{import_resource_parent_diagnostic, synthesize_repo_aliases}; +use super::merge::merge_resolved; +use super::{ImportProvenance, ManifestFetcher, ResolvedImport, resolve_imports}; +use crate::compile::types::{CompileTarget, ImportEntry, ImportSource, ParsedImportSpec}; +use crate::secure::CommitSha; + +const SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + +struct PanicFetcher; + +impl ManifestFetcher for PanicFetcher { + fn fetch(&self, _spec: &ParsedImportSpec) -> Result> { + panic!("integration tests must not fetch remote imports") + } +} + +fn temp_repo() -> tempfile::TempDir { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("imports-integration-tmp"); + fs::create_dir_all(&root).expect("create integration temp root"); + tempfile::Builder::new() + .prefix("repo-") + .tempdir_in(root) + .expect("create temp repo") +} + +fn key(name: &str) -> Value { + Value::String(name.to_string()) +} + +fn ymap(yaml: &str) -> Mapping { + match serde_yaml::from_str::(yaml).expect("valid YAML") { + Value::Mapping(mapping) => mapping, + other => panic!("expected mapping, got {other:?}"), + } +} + +fn map_get<'a>(mapping: &'a Mapping, name: &str) -> &'a Value { + mapping + .get(key(name)) + .unwrap_or_else(|| panic!("expected mapping key `{name}`")) +} + +fn map_get_mapping<'a>(mapping: &'a Mapping, name: &str) -> &'a Mapping { + value_as_mapping(map_get(mapping, name)) +} + +fn value_as_mapping(value: &Value) -> &Mapping { + match value { + Value::Mapping(mapping) => mapping, + other => panic!("expected mapping value, got {other:?}"), + } +} + +fn import_entry(uses: &str) -> ImportEntry { + ImportEntry { + uses: uses.to_string(), + with: serde_json::Map::new(), + endpoint: None, + } +} + +fn parse_workflow(path: &Path) -> (Mapping, String, Vec) { + let content = fs::read_to_string(path).expect("read workflow"); + let parts = crate::compile::common::split_markdown_front_matter(&content, true) + .expect("split workflow front matter"); + let front_matter = match serde_yaml::from_str::( + parts.yaml_raw.as_deref().expect("front matter exists"), + ) + .expect("parse workflow front matter") + { + Value::Mapping(mapping) => mapping, + other => panic!("expected workflow mapping, got {other:?}"), + }; + let imports = front_matter + .get(key("imports")) + .map(|value| { + serde_yaml::from_value::>(value.clone()).expect("deserialize imports") + }) + .unwrap_or_default(); + + (front_matter, parts.markdown_body, imports) +} + +fn write_component(dir: &Path, name: &str, content: &str) { + let path = dir.join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create component parent"); + } + fs::write(path, content).expect("write component"); +} + +fn resolve_local(entries: &[ImportEntry], base_dir: &Path) -> Vec { + resolve_imports(entries, base_dir, &PanicFetcher).expect("resolve local imports") +} + +#[test] +fn imports_integration_local_resolve_then_merge_consumer_wins_and_unions() { + let repo = temp_repo(); + let workflow_dir = repo.path().join("workflows"); + fs::create_dir_all(&workflow_dir).expect("create workflow dir"); + write_component( + &workflow_dir, + "components/notify.md", + r#"--- +target: 1es +tools: + edit: {} +safe-outputs: + imported-notify: + run: node scripts/notify.js + inputs: + message: + type: string +--- +Imported guidance. +"#, + ); + let workflow_path = workflow_dir.join("agent.md"); + fs::write( + &workflow_path, + r#"--- +name: consumer +description: consumer workflow +target: standalone +imports: + - components/notify.md +tools: + bash: {} +--- +Consumer guidance. +"#, + ) + .expect("write workflow"); + + let (mut consumer_fm, consumer_body, entries) = parse_workflow(&workflow_path); + let resolved = resolve_local(&entries, &workflow_dir); + let merged_body = + merge_resolved(&mut consumer_fm, &consumer_body, &resolved).expect("merge imports"); + + assert_eq!( + map_get(&consumer_fm, "target"), + &Value::String("standalone".into()) + ); + assert!( + !consumer_fm.contains_key(key("imports")), + "imports key should be consumed" + ); + let tools = map_get_mapping(&consumer_fm, "tools"); + assert!(tools.contains_key(key("edit")), "imported tool missing"); + assert!(tools.contains_key(key("bash")), "consumer tool missing"); + let safe_outputs = map_get_mapping(&consumer_fm, "safe-outputs"); + assert!(safe_outputs.contains_key(key("imported-notify"))); + assert_eq!(merged_body, "Imported guidance.\n\nConsumer guidance."); +} + +#[test] +fn imports_integration_schema_inputs_are_substituted_before_merge() { + let repo = temp_repo(); + let workflow_dir = repo.path().join("workflows"); + fs::create_dir_all(&workflow_dir).expect("create workflow dir"); + write_component( + &workflow_dir, + "components/deploy.md", + r#"--- +import-schema: + destination: + type: string + required: true +safe-outputs: + deploy: + run: "deploy --to ${{ ado.aw.import-inputs.destination }}" + env: + DESTINATION: "${{ ado.aw.import-inputs.destination }}" +--- +Deploy to ${{ ado.aw.import-inputs.destination }}. +"#, + ); + let workflow_path = workflow_dir.join("agent.md"); + fs::write( + &workflow_path, + r#"--- +name: consumer +description: consumer workflow +imports: + - uses: components/deploy.md + with: + destination: prod-west +--- +Consumer body. +"#, + ) + .expect("write workflow"); + + let (mut consumer_fm, consumer_body, entries) = parse_workflow(&workflow_path); + let resolved = resolve_local(&entries, &workflow_dir); + let merged_body = + merge_resolved(&mut consumer_fm, &consumer_body, &resolved).expect("merge imports"); + + assert!(!consumer_fm.contains_key(key("import-schema"))); + let safe_outputs = map_get_mapping(&consumer_fm, "safe-outputs"); + let deploy = value_as_mapping( + safe_outputs + .get(key("deploy")) + .expect("deploy safe-output present"), + ); + assert_eq!( + map_get(deploy, "run"), + &Value::String("deploy --to prod-west".into()) + ); + let env = map_get_mapping(deploy, "env"); + assert_eq!( + map_get(env, "DESTINATION"), + &Value::String("prod-west".into()) + ); + assert_eq!(merged_body, "Deploy to prod-west.\n\nConsumer body."); +} + +#[test] +fn imports_integration_remote_specs_must_be_sha_pinned() { + let err = ImportEntry { + uses: "o/r/p.md@main".to_string(), + with: serde_json::Map::new(), + endpoint: None, + } + .parse_source() + .expect_err("branch refs must be rejected"); + + assert!( + err.to_string().contains("full 40-character commit SHA"), + "{err}" + ); +} + +#[test] +fn imports_integration_merge_conflicts_and_safe_output_configuration() { + let repo = temp_repo(); + let workflow_dir = repo.path().join("workflows"); + fs::create_dir_all(&workflow_dir).expect("create workflow dir"); + write_component( + &workflow_dir, + "tool-one.md", + "---\ntools:\n edit: {}\n---\none\n", + ); + write_component( + &workflow_dir, + "tool-two.md", + "---\ntools:\n edit: {}\n---\ntwo\n", + ); + let duplicate_tools = resolve_local( + &[import_entry("tool-one.md"), import_entry("tool-two.md")], + &workflow_dir, + ); + let err = merge_resolved(&mut ymap("name: consumer"), "", &duplicate_tools) + .expect_err("duplicate imported tools should fail"); + assert!(err.to_string().contains("tools.edit"), "{err}"); + + write_component( + &workflow_dir, + "notify.md", + "---\nsafe-outputs:\n notify:\n run: notify.js\n---\nnotify\n", + ); + let notify = resolve_local(&[import_entry("notify.md")], &workflow_dir); + let err = merge_resolved( + &mut ymap("safe-outputs:\n notify:\n run: consumer.js"), + "", + ¬ify, + ) + .expect_err("consumer executor redefinition should fail"); + assert!(err.to_string().contains("executor"), "{err}"); + + let mut consumer = ymap("safe-outputs:\n notify:\n require-approval: true"); + merge_resolved(&mut consumer, "", ¬ify).expect("configuration overlay should succeed"); + let safe_outputs = map_get_mapping(&consumer, "safe-outputs"); + let notify_cfg = value_as_mapping( + safe_outputs + .get(key("notify")) + .expect("notify safe-output present"), + ); + assert_eq!( + map_get(notify_cfg, "run"), + &Value::String("notify.js".into()) + ); + assert_eq!(map_get(notify_cfg, "require-approval"), &Value::Bool(true)); +} + +#[test] +fn imports_integration_resolve_enforces_import_count_limit() { + let repo = temp_repo(); + let entries: Vec = (0..21) + .map(|idx| import_entry(&format!("missing-{idx}.md?"))) + .collect(); + + let err = resolve_imports(&entries, repo.path(), &PanicFetcher) + .expect_err("more than 20 imports should fail before resolution"); + + assert!( + err.to_string() + .contains("imports per workflow must be <= 20"), + "{err}" + ); +} + +#[test] +fn imports_integration_remote_alias_synthesis_and_template_diagnostic() { + let import = ResolvedImport { + entry: ImportEntry { + uses: format!("octo/components/deploy.md@{SHA}"), + with: serde_json::Map::new(), + endpoint: Some("github-service-connection".to_string()), + }, + source: ImportSource::Remote(ParsedImportSpec { + owner: "octo".to_string(), + repo: "components".to_string(), + path: "deploy.md".to_string(), + sha: CommitSha::parse(SHA).expect("valid sha"), + section: None, + optional: false, + }), + front_matter: Value::Null, + body: String::new(), + provenance: ImportProvenance { + source: "octo/components/deploy.md".to_string(), + sha: Some(SHA.to_string()), + manifest_digest: "digest".to_string(), + }, + }; + + let repos = synthesize_repo_aliases(&[import]).expect("synthesize aliases"); + + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].repo_type, "github"); + assert_eq!( + repos[0].endpoint.as_deref(), + Some("github-service-connection") + ); + assert_eq!(repos[0].name, "octo/components"); + let aliases = repos + .iter() + .map(|repo| repo.repository.clone()) + .collect::>(); + let diagnostic = import_resource_parent_diagnostic(CompileTarget::Job, &aliases) + .expect("job template target should report parent resource diagnostic"); + assert!(diagnostic.contains(&aliases[0]), "{diagnostic}"); + assert!(diagnostic.contains("parent pipeline"), "{diagnostic}"); +} diff --git a/src/compile/imports/merge.rs b/src/compile/imports/merge.rs new file mode 100644 index 00000000..904f16c0 --- /dev/null +++ b/src/compile/imports/merge.rs @@ -0,0 +1,469 @@ +//! Consumer-wins front-matter merge for `imports:` (decision D9). +//! +//! Merges the front matter and body of resolved imported components into the +//! consumer workflow. Precedence is **consumer > later import > earlier +//! import**: +//! +//! * **Scalar / singleton keys** (`name`, `engine`, `target`, …): the +//! highest-precedence explicit setter wins. No error. +//! * **Collection keys** (`tools`, `mcp-servers`, `safe-outputs`, `runtimes`, +//! `env`): additive union by sub-key. A sub-key defined by **two different +//! imports** is a hard error. The consumer may **configure** an imported +//! `safe-outputs` tool (overlay non-executor config) but may **not** redefine +//! its executor (`steps`/`env`/`inputs`/`run`/`entrypoint`) — that is a hard +//! error. +//! * **Sequence keys** (`parameters`, `repos`, `variable-groups`): additive +//! concatenation (imports first, then consumer). +//! * **Body**: imported bodies are concatenated in declaration order, then the +//! consumer body. +//! +//! The merge runs only when the consumer declares `imports:`; with no imports +//! it is never invoked, so existing workflows are unaffected. +#![allow(dead_code)] + +use std::path::Path; + +use anyhow::{Context, Result}; +use serde_yaml::{Mapping, Value}; + +use super::schema::apply_import_inputs; +use super::{ManifestFetcher, ResolvedImport, resolve_imports_with_repo_root}; +use crate::compile::types::ImportEntry; + +/// Front-matter keys whose values are mappings merged additively by sub-key. +const COLLECTION_MAP_KEYS: &[&str] = &["tools", "mcp-servers", "safe-outputs", "runtimes", "env"]; + +/// Front-matter keys whose values are sequences merged by concatenation. +const SEQUENCE_KEYS: &[&str] = &["parameters", "repos", "variable-groups"]; + +/// `safe-outputs` sub-keys that define a tool's executor. A consumer may +/// configure an imported tool but may not redefine these. +const EXECUTOR_KEYS: &[&str] = &["steps", "env", "inputs", "run", "entrypoint"]; + +/// Resolve the consumer's imports, apply their `import-schema` inputs, and +/// merge their front matter + body into `consumer_fm` / the returned body. +/// +/// Returns the merged markdown body. `consumer_fm` is mutated in place and its +/// `imports:` key is removed (imports are consumed by this pass). +pub fn merge_imports( + consumer_fm: &mut Mapping, + consumer_body: &str, + entries: &[ImportEntry], + base_dir: &Path, + repo_root: &Path, + fetcher: &dyn ManifestFetcher, +) -> Result { + let resolved = resolve_imports_with_repo_root(entries, base_dir, repo_root, fetcher)?; + merge_resolved(consumer_fm, consumer_body, &resolved) +} + +/// Merge already-resolved imports (test-friendly seam that takes no fetcher). +pub fn merge_resolved( + consumer_fm: &mut Mapping, + consumer_body: &str, + resolved: &[ResolvedImport], +) -> Result { + // Accumulate imported front matter in declaration order (import-vs-import + // rules), then overlay the consumer on top. + let mut acc = Mapping::new(); + let mut acc_provenance: std::collections::HashMap = + std::collections::HashMap::new(); + let mut body_parts: Vec = Vec::new(); + + for (idx, import) in resolved.iter().enumerate() { + let (sub_fm, sub_body) = + apply_import_inputs(&import.front_matter, &import.body, &import.entry.with) + .with_context(|| { + format!( + "failed to apply import inputs for '{}'", + import.provenance.source + ) + })?; + + if let Value::Mapping(component_map) = &sub_fm { + merge_import_into_acc( + &mut acc, + &mut acc_provenance, + component_map, + idx, + &import.provenance.source, + )?; + } + + let trimmed = sub_body.trim(); + if !trimmed.is_empty() { + body_parts.push(trimmed.to_string()); + } + } + + // Overlay the consumer front matter on top of the accumulated imports. + overlay_consumer(&mut acc, consumer_fm)?; + + // The merged mapping replaces the consumer mapping; drop the now-consumed + // `imports` key. + acc.remove(Value::String("imports".to_string())); + *consumer_fm = acc; + + // Body: imported bodies (declaration order) then the consumer body. + let consumer_trimmed = consumer_body.trim(); + if !consumer_trimmed.is_empty() { + body_parts.push(consumer_trimmed.to_string()); + } + Ok(body_parts.join("\n\n")) +} + +/// Merge one import's mapping into the accumulator, enforcing import-vs-import +/// collision rules. +fn merge_import_into_acc( + acc: &mut Mapping, + provenance: &mut std::collections::HashMap, + component: &Mapping, + import_idx: usize, + source: &str, +) -> Result<()> { + for (key, value) in component { + let key_str = match key.as_str() { + Some(k) => k.to_string(), + None => continue, + }; + // `import-schema` is consumed by substitution and must never leak into + // the merged workflow. + if key_str == "import-schema" || key_str == "imports" { + continue; + } + + if is_collection_map_key(&key_str) { + merge_map_key( + acc, + &key_str, + value, + MergeSide::Import { + idx: import_idx, + source, + }, + provenance, + )?; + } else if is_sequence_key(&key_str) { + concat_sequence(acc, &key_str, value); + } else { + // Scalar/singleton: later import wins over earlier. + acc.insert(Value::String(key_str), value.clone()); + } + } + Ok(()) +} + +/// Overlay the consumer front matter on top of the accumulated imports. +fn overlay_consumer(acc: &mut Mapping, consumer: &Mapping) -> Result<()> { + for (key, value) in consumer { + let key_str = match key.as_str() { + Some(k) => k.to_string(), + None => continue, + }; + if key_str == "imports" { + continue; + } + + if is_collection_map_key(&key_str) { + merge_map_key( + acc, + &key_str, + value, + MergeSide::Consumer, + &mut Default::default(), + )?; + } else if is_sequence_key(&key_str) { + concat_sequence(acc, &key_str, value); + } else { + // Scalar/singleton: consumer wins. + acc.insert(Value::String(key_str), value.clone()); + } + } + Ok(()) +} + +enum MergeSide<'a> { + Import { idx: usize, source: &'a str }, + Consumer, +} + +/// Merge a collection-map key (e.g. `tools`) into the accumulator, applying the +/// per-sub-key collision rules. +fn merge_map_key( + acc: &mut Mapping, + key: &str, + incoming: &Value, + side: MergeSide<'_>, + provenance: &mut std::collections::HashMap, +) -> Result<()> { + let Value::Mapping(incoming_map) = incoming else { + // Non-mapping value under a collection key: treat as scalar overwrite. + acc.insert(Value::String(key.to_string()), incoming.clone()); + return Ok(()); + }; + + let entry = acc + .entry(Value::String(key.to_string())) + .or_insert_with(|| Value::Mapping(Mapping::new())); + let Value::Mapping(existing) = entry else { + // Existing non-mapping (unusual) — replace wholesale. + *entry = incoming.clone(); + return Ok(()); + }; + + for (sub_key, sub_val) in incoming_map { + let sub_name = match sub_key.as_str() { + Some(s) => s.to_string(), + None => continue, + }; + let prov_key = format!("{key}.{sub_name}"); + let already = existing.contains_key(sub_key); + + match &side { + MergeSide::Import { idx, source } => { + if already { + // Collision between two imports is a hard error. + let prev = provenance.get(&prov_key).copied(); + if prev.is_some() && prev != Some(*idx) { + anyhow::bail!( + "import conflict: '{key}.{sub_name}' is defined by more than one \ + imported component (latest from '{source}'). Imported \ + {key} entries must have unique names." + ); + } + } + existing.insert(sub_key.clone(), sub_val.clone()); + provenance.insert(prov_key, *idx); + } + MergeSide::Consumer => { + if already && key == "safe-outputs" { + // Consumer may configure an imported tool but not redefine + // its executor. + configure_safe_output(existing, sub_key, sub_val, &sub_name)?; + } else if already { + anyhow::bail!( + "import conflict: the consumer redefines '{key}.{sub_name}', which is \ + already provided by an imported component. Collections merge \ + additively; rename or remove the duplicate." + ); + } else { + existing.insert(sub_key.clone(), sub_val.clone()); + } + } + } + } + Ok(()) +} + +/// Overlay consumer configuration onto an imported `safe-outputs` tool without +/// allowing executor redefinition. +fn configure_safe_output( + existing: &mut Mapping, + sub_key: &Value, + incoming: &Value, + sub_name: &str, +) -> Result<()> { + let existing_val = existing.get_mut(sub_key); + match (existing_val, incoming) { + (Some(Value::Mapping(existing_cfg)), Value::Mapping(incoming_cfg)) => { + for (cfg_key, cfg_val) in incoming_cfg { + if let Some(name) = cfg_key.as_str() + && EXECUTOR_KEYS.contains(&name) + { + anyhow::bail!( + "import conflict: the consumer may configure the imported \ + safe-output '{sub_name}' but not redefine its executor \ + ('{name}' is executor-defining)." + ); + } + existing_cfg.insert(cfg_key.clone(), cfg_val.clone()); + } + Ok(()) + } + // If the consumer provides a non-mapping config (e.g. `true`), overlay + // it as the tool value. + (Some(slot), _) => { + *slot = incoming.clone(); + Ok(()) + } + (None, _) => { + existing.insert(sub_key.clone(), incoming.clone()); + Ok(()) + } + } +} + +/// Concatenate a sequence-valued key (imports first, then consumer). +fn concat_sequence(acc: &mut Mapping, key: &str, incoming: &Value) { + let Value::Sequence(incoming_seq) = incoming else { + acc.insert(Value::String(key.to_string()), incoming.clone()); + return; + }; + let entry = acc + .entry(Value::String(key.to_string())) + .or_insert_with(|| Value::Sequence(Vec::new())); + if let Value::Sequence(existing) = entry { + existing.extend(incoming_seq.iter().cloned()); + } else { + *entry = incoming.clone(); + } +} + +fn is_collection_map_key(key: &str) -> bool { + COLLECTION_MAP_KEYS.contains(&key) +} + +fn is_sequence_key(key: &str) -> bool { + SEQUENCE_KEYS.contains(&key) +} + +#[cfg(test)] +mod tests { + use super::super::ImportProvenance; + use super::*; + use crate::compile::types::ImportSource; + + fn ymap(yaml: &str) -> Mapping { + match serde_yaml::from_str::(yaml).unwrap() { + Value::Mapping(m) => m, + _ => panic!("expected mapping"), + } + } + + fn resolved(fm_yaml: &str, body: &str) -> ResolvedImport { + ResolvedImport { + entry: ImportEntry { + uses: "local.md".to_string(), + with: serde_json::Map::new(), + endpoint: None, + }, + source: ImportSource::Local { + path: "local.md".to_string(), + section: None, + optional: false, + }, + front_matter: serde_yaml::from_str(fm_yaml).unwrap(), + body: body.to_string(), + provenance: ImportProvenance { + source: "local.md".to_string(), + sha: None, + manifest_digest: "d".to_string(), + }, + } + } + + #[test] + fn consumer_wins_for_scalars() { + let mut consumer = ymap("engine: copilot\nname: consumer"); + let imports = vec![resolved("engine: claude\ntarget: 1es", "")]; + merge_resolved(&mut consumer, "", &imports).unwrap(); + assert_eq!( + consumer[Value::String("engine".into())], + Value::String("copilot".into()) + ); + // Import-only scalar is adopted. + assert_eq!( + consumer[Value::String("target".into())], + Value::String("1es".into()) + ); + } + + #[test] + fn later_import_wins_over_earlier_for_scalars() { + let mut consumer = ymap("name: c"); + let imports = vec![resolved("engine: a", ""), resolved("engine: b", "")]; + merge_resolved(&mut consumer, "", &imports).unwrap(); + assert_eq!( + consumer[Value::String("engine".into())], + Value::String("b".into()) + ); + } + + #[test] + fn collections_union_additively() { + let mut consumer = ymap("tools:\n bash: {}"); + let imports = vec![resolved("tools:\n edit: {}", "")]; + merge_resolved(&mut consumer, "", &imports).unwrap(); + let tools = consumer[Value::String("tools".into())] + .as_mapping() + .unwrap(); + assert!(tools.contains_key(Value::String("bash".into()))); + assert!(tools.contains_key(Value::String("edit".into()))); + } + + #[test] + fn import_vs_import_collection_collision_errors() { + let mut consumer = ymap("name: c"); + let imports = vec![ + resolved("mcp-servers:\n x:\n url: a", ""), + resolved("mcp-servers:\n x:\n url: b", ""), + ]; + let err = merge_resolved(&mut consumer, "", &imports).unwrap_err(); + assert!(err.to_string().contains("more than one"), "{err}"); + } + + #[test] + fn consumer_redefining_imported_tool_errors() { + let mut consumer = ymap("tools:\n edit: {}"); + let imports = vec![resolved("tools:\n edit: {}", "")]; + let err = merge_resolved(&mut consumer, "", &imports).unwrap_err(); + assert!(err.to_string().contains("redefines"), "{err}"); + } + + #[test] + fn consumer_may_configure_imported_safe_output() { + let mut consumer = ymap("safe-outputs:\n notify:\n require-approval: true"); + let imports = vec![resolved( + "safe-outputs:\n notify:\n run: node notify.js\n max: 3", + "", + )]; + merge_resolved(&mut consumer, "", &imports).unwrap(); + let so = consumer[Value::String("safe-outputs".into())] + .as_mapping() + .unwrap(); + let notify = so[Value::String("notify".into())].as_mapping().unwrap(); + assert_eq!( + notify[Value::String("require-approval".into())], + Value::Bool(true) + ); + // Imported executor config is preserved. + assert_eq!( + notify[Value::String("run".into())], + Value::String("node notify.js".into()) + ); + } + + #[test] + fn consumer_redefining_safe_output_executor_errors() { + let mut consumer = ymap("safe-outputs:\n notify:\n run: evil.js"); + let imports = vec![resolved("safe-outputs:\n notify:\n run: notify.js", "")]; + let err = merge_resolved(&mut consumer, "", &imports).unwrap_err(); + assert!(err.to_string().contains("executor"), "{err}"); + } + + #[test] + fn body_concatenated_imports_then_consumer() { + let mut consumer = ymap("name: c"); + let imports = vec![resolved("name: i", "IMPORT BODY")]; + let body = merge_resolved(&mut consumer, "CONSUMER BODY", &imports).unwrap(); + assert_eq!(body, "IMPORT BODY\n\nCONSUMER BODY"); + } + + #[test] + fn imports_key_removed_after_merge() { + let mut consumer = ymap("imports:\n - local.md\nname: c"); + merge_resolved(&mut consumer, "", &[]).unwrap(); + assert!(!consumer.contains_key(Value::String("imports".into()))); + } + + #[test] + fn sequences_concatenated() { + let mut consumer = ymap("parameters:\n - name: p2"); + let imports = vec![resolved("parameters:\n - name: p1", "")]; + merge_resolved(&mut consumer, "", &imports).unwrap(); + let params = consumer[Value::String("parameters".into())] + .as_sequence() + .unwrap(); + assert_eq!(params.len(), 2); + } +} diff --git a/src/compile/imports/mod.rs b/src/compile/imports/mod.rs new file mode 100644 index 00000000..73a8b25e --- /dev/null +++ b/src/compile/imports/mod.rs @@ -0,0 +1,621 @@ +//! Compile-time resolution for `imports:` entries. +//! +//! This module deliberately stops at resolution: it fetches/loads the imported +//! markdown manifest, parses its front matter and body, and records provenance. +//! Merging imported content into the consumer workflow is a later compile pass. +#![allow(dead_code)] + +pub mod alias; +#[cfg(test)] +mod integration_tests; +pub mod merge; +pub mod schema; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::Deserialize; + +use crate::compile::types::{ImportEntry, ImportSource, ParsedImportSpec}; +use crate::hash::sha256_hex; + +const MAX_IMPORTS_PER_WORKFLOW: usize = 20; +const MAX_MANIFEST_BYTES: usize = 256 * 1024; +const IMPORT_GITATTRIBUTES: &str = "# Mark all cached import files as generated\n\ +* linguist-generated=true\n\ +# Keep local cached versions on merge\n\ +* merge=ours\n"; + +/// Fetches a single SHA-pinned component manifest. +pub trait ManifestFetcher { + fn fetch(&self, spec: &ParsedImportSpec) -> Result>; +} + +/// GitHub Contents API-backed manifest fetcher using the author's `gh` auth. +pub struct GhCliFetcher; + +impl ManifestFetcher for GhCliFetcher { + fn fetch(&self, spec: &ParsedImportSpec) -> Result> { + // TODO: Azure Repos manifest fetch (follow-up). + let route = format!( + "repos/{}/{}/contents/{}?ref={}", + spec.owner, + spec.repo, + spec.path, + spec.sha.as_str() + ); + let output = Command::new("gh") + .args(["api", &route]) + .output() + .with_context(|| format!("failed to run `gh api {route}` for import manifest"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "`gh api {}` failed with status {}: {}", + route, + output.status, + stderr.trim() + ); + } + + #[derive(Deserialize)] + struct ContentsResponse { + content: String, + #[serde(default)] + encoding: Option, + } + + let response: ContentsResponse = serde_json::from_slice(&output.stdout) + .with_context(|| format!("failed to parse GitHub Contents API response for {route}"))?; + if response.encoding.as_deref().unwrap_or("base64") != "base64" { + anyhow::bail!( + "GitHub Contents API response for {} used unsupported encoding {:?}", + route, + response.encoding + ); + } + + let compact_content: String = response + .content + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect(); + STANDARD + .decode(compact_content.as_bytes()) + .with_context(|| { + format!("failed to base64-decode GitHub Contents API response for {route}") + }) + } +} + +/// A resolved import manifest plus source provenance. +#[derive(Debug, Clone)] +pub struct ResolvedImport { + pub entry: ImportEntry, + pub source: ImportSource, + pub front_matter: serde_yaml::Value, + pub body: String, + pub provenance: ImportProvenance, +} + +/// Audit provenance for a resolved import manifest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportProvenance { + pub source: String, + pub sha: Option, + pub manifest_digest: String, +} + +/// Resolve top-level imports using `base_dir` for local paths and cache root. +/// +/// Prefer [`resolve_imports_with_repo_root`] when the workflow directory is not +/// the repository root. This function is kept as the simple public entry point +/// for callers that compile from the repo root. +pub fn resolve_imports( + entries: &[ImportEntry], + base_dir: &Path, + fetcher: &dyn ManifestFetcher, +) -> Result> { + resolve_imports_with_repo_root(entries, base_dir, base_dir, fetcher) +} + +/// Resolve top-level imports using an explicit repo root for the committed +/// `.ado-aw/imports` cache. +/// +/// TODO: nested imports (depth<=3). This pass intentionally resolves only the +/// workflow's declared top-level `imports:` list; transitive resolution will +/// layer on this entry point in a later merge pass. +pub fn resolve_imports_with_repo_root( + entries: &[ImportEntry], + base_dir: &Path, + repo_root: &Path, + fetcher: &dyn ManifestFetcher, +) -> Result> { + if entries.len() > MAX_IMPORTS_PER_WORKFLOW { + anyhow::bail!( + "imports per workflow must be <= {}, got {}", + MAX_IMPORTS_PER_WORKFLOW, + entries.len() + ); + } + + let mut resolved = Vec::new(); + for entry in entries { + if let Some(import) = resolve_one(entry, base_dir, repo_root, fetcher) + .with_context(|| format!("failed to resolve import `{}`", entry.uses))? + { + resolved.push(import); + } + } + Ok(resolved) +} + +fn resolve_one( + entry: &ImportEntry, + base_dir: &Path, + repo_root: &Path, + fetcher: &dyn ManifestFetcher, +) -> Result> { + let source = entry.parse_source()?; + match &source { + ImportSource::Local { + path, + section, + optional, + } => { + let local_path = resolve_local_path(base_dir, path)?; + let bytes = match fs::read(&local_path) { + Ok(bytes) => bytes, + Err(err) if *optional && err.kind() == std::io::ErrorKind::NotFound => { + return Ok(None); + } + Err(err) => { + return Err(err).with_context(|| { + format!("failed to read local import {}", local_path.display()) + }); + } + }; + let digest = sha256_hex(&bytes); + let (front_matter, body) = parse_manifest_bytes(&bytes, section.as_deref())?; + Ok(Some(ResolvedImport { + entry: entry.clone(), + source: source.clone(), + front_matter, + body, + provenance: ImportProvenance { + source: path.clone(), + sha: None, + manifest_digest: digest, + }, + })) + } + ImportSource::Remote(spec) => { + let bytes = read_remote_manifest(repo_root, spec, fetcher)?; + let digest = sha256_hex(&bytes); + let (front_matter, body) = parse_manifest_bytes(&bytes, spec.section.as_deref())?; + Ok(Some(ResolvedImport { + entry: entry.clone(), + source: source.clone(), + front_matter, + body, + provenance: ImportProvenance { + source: format!("{}/{}/{}", spec.owner, spec.repo, spec.path), + sha: Some(spec.sha.as_str().to_string()), + manifest_digest: digest, + }, + })) + } + } +} + +fn resolve_local_path(base_dir: &Path, import_path: &str) -> Result { + let path = Path::new(import_path); + if path.is_absolute() { + anyhow::bail!("local import path must be relative, got `{}`", import_path); + } + Ok(base_dir.join(path)) +} + +fn read_remote_manifest( + repo_root: &Path, + spec: &ParsedImportSpec, + fetcher: &dyn ManifestFetcher, +) -> Result> { + let cache_path = cache_path(repo_root, spec)?; + if cache_path.exists() { + let bytes = fs::read(&cache_path) + .with_context(|| format!("failed to read cached import {}", cache_path.display()))?; + enforce_manifest_size(bytes.len(), &cache_path.display().to_string())?; + return Ok(bytes); + } + + let bytes = fetcher.fetch(spec)?; + enforce_manifest_size( + bytes.len(), + &format!("{}/{}/{}", spec.owner, spec.repo, spec.path), + )?; + + let parent = cache_path + .parent() + .context("import cache path unexpectedly has no parent")?; + fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create import cache directory {}", + parent.display() + ) + })?; + ensure_import_gitattributes(repo_root)?; + fs::write(&cache_path, &bytes) + .with_context(|| format!("failed to write cached import {}", cache_path.display()))?; + Ok(bytes) +} + +fn enforce_manifest_size(size: usize, source: &str) -> Result<()> { + if size > MAX_MANIFEST_BYTES { + anyhow::bail!( + "import manifest {} is {} bytes, exceeding the {} byte limit", + source, + size, + MAX_MANIFEST_BYTES + ); + } + Ok(()) +} + +fn cache_path(repo_root: &Path, spec: &ParsedImportSpec) -> Result { + validate_cache_segment("owner", &spec.owner)?; + validate_cache_segment("repo", &spec.repo)?; + let flat_path = flatten_import_path(&spec.path)?; + Ok(repo_root + .join(".ado-aw") + .join("imports") + .join(&spec.owner) + .join(&spec.repo) + .join(spec.sha.as_str()) + .join(flat_path)) +} + +fn validate_cache_segment(label: &str, value: &str) -> Result<()> { + if value.is_empty() + || value == "." + || value == ".." + || value.contains('\\') + || value.contains('/') + { + anyhow::bail!( + "remote import {} contains an invalid path segment: `{}`", + label, + value + ); + } + Ok(()) +} + +fn flatten_import_path(path: &str) -> Result { + if path.is_empty() + || path.contains('\\') + || path + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + { + anyhow::bail!("remote import path contains an invalid segment: `{}`", path); + } + Ok(path.replace('/', "_")) +} + +fn ensure_import_gitattributes(repo_root: &Path) -> Result<()> { + let imports_dir = repo_root.join(".ado-aw").join("imports"); + fs::create_dir_all(&imports_dir).with_context(|| { + format!( + "failed to create import cache attributes directory {}", + imports_dir.display() + ) + })?; + let attributes_path = imports_dir.join(".gitattributes"); + if !attributes_path.exists() { + fs::write(&attributes_path, IMPORT_GITATTRIBUTES).with_context(|| { + format!( + "failed to write import cache attributes {}", + attributes_path.display() + ) + })?; + } + Ok(()) +} + +fn parse_manifest_bytes( + bytes: &[u8], + section: Option<&str>, +) -> Result<(serde_yaml::Value, String)> { + enforce_manifest_size(bytes.len(), "resolved import manifest")?; + let content = + std::str::from_utf8(bytes).context("import manifest must be valid UTF-8 markdown")?; + let parts = super::common::split_markdown_front_matter(content, false)?; + let front_matter = match parts.yaml_raw { + Some(yaml) => { + let value: serde_yaml::Value = + serde_yaml::from_str(&yaml).context("failed to parse import YAML front matter")?; + match value { + serde_yaml::Value::Mapping(_) | serde_yaml::Value::Null => value, + other => { + anyhow::bail!( + "import YAML front matter must be a mapping/object, got {}", + yaml_value_kind(&other) + ); + } + } + } + None => serde_yaml::Value::Null, + }; + + let body = match section { + Some(name) => extract_markdown_section(&parts.markdown_body, name)?, + None => parts.markdown_body, + }; + Ok((front_matter, body)) +} + +fn yaml_value_kind(value: &serde_yaml::Value) -> &'static str { + match value { + serde_yaml::Value::Null => "null", + serde_yaml::Value::Bool(_) => "boolean", + serde_yaml::Value::Number(_) => "number", + serde_yaml::Value::String(_) => "string", + serde_yaml::Value::Sequence(_) => "sequence/array", + serde_yaml::Value::Mapping(_) => "mapping/object", + serde_yaml::Value::Tagged(_) => "tagged value", + } +} + +/// Extract a markdown `# Name` / `## Name` section, including its heading. +fn extract_markdown_section(body: &str, section: &str) -> Result { + let lines: Vec<&str> = body.lines().collect(); + let start = lines + .iter() + .position(|line| markdown_heading(line).is_some_and(|(_, name)| name == section)) + .ok_or_else(|| anyhow::anyhow!("import section `{}` was not found", section))?; + let start_level = markdown_heading(lines[start]) + .map(|(level, _)| level) + .expect("line matched heading above"); + + let end = lines + .iter() + .enumerate() + .skip(start + 1) + .find_map(|(idx, line)| match markdown_heading(line) { + Some((level, _)) if level <= start_level => Some(idx), + _ => None, + }) + .unwrap_or(lines.len()); + + Ok(lines[start..end].join("\n").trim().to_string()) +} + +fn markdown_heading(line: &str) -> Option<(usize, &str)> { + let trimmed = line.trim_start(); + let level = trimmed.bytes().take_while(|byte| *byte == b'#').count(); + if !(level == 1 || level == 2) { + return None; + } + let rest = &trimmed[level..]; + if !rest.starts_with(char::is_whitespace) { + return None; + } + let name = rest + .trim() + .trim_end_matches('#') + .trim_end() + .trim() + .trim_end_matches('\r'); + if name.is_empty() { + return None; + } + Some((level, name)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + struct StaticFetcher { + bytes: Vec, + } + + impl ManifestFetcher for StaticFetcher { + fn fetch(&self, _spec: &ParsedImportSpec) -> Result> { + Ok(self.bytes.clone()) + } + } + + struct PanicFetcher; + + impl ManifestFetcher for PanicFetcher { + fn fetch(&self, _spec: &ParsedImportSpec) -> Result> { + panic!("fetcher must not be called on cache hit") + } + } + + fn import_entry(uses: &str) -> ImportEntry { + ImportEntry { + uses: uses.to_string(), + with: serde_json::Map::new(), + endpoint: None, + } + } + + fn manifest() -> &'static [u8] { + b"---\nimport-schema:\n region:\n type: string\n---\n# Imported\nBody\n" + } + + #[test] + fn local_import_resolves_front_matter_body_and_provenance() { + let repo = tempfile::tempdir().unwrap(); + let workflow_dir = repo.path().join("workflows"); + fs::create_dir_all(&workflow_dir).unwrap(); + let import_path = workflow_dir.join("component.md"); + fs::write(&import_path, manifest()).unwrap(); + + let resolved = resolve_imports_with_repo_root( + &[import_entry("component.md")], + &workflow_dir, + repo.path(), + &PanicFetcher, + ) + .unwrap(); + + assert_eq!(resolved.len(), 1); + assert!(resolved[0].front_matter["import-schema"].is_mapping()); + assert_eq!(resolved[0].body, "# Imported\nBody"); + assert_eq!(resolved[0].provenance.source, "component.md"); + assert_eq!(resolved[0].provenance.sha, None); + assert_eq!( + resolved[0].provenance.manifest_digest, + sha256_hex(manifest()) + ); + } + + #[test] + fn remote_import_fetches_writes_cache_attributes_and_records_digest() { + let repo = tempfile::tempdir().unwrap(); + let entry = import_entry(&format!("acme/shared/components/deploy.md@{SHA}")); + let fetcher = StaticFetcher { + bytes: manifest().to_vec(), + }; + + let resolved = + resolve_imports_with_repo_root(&[entry], repo.path(), repo.path(), &fetcher).unwrap(); + + let cache_file = repo + .path() + .join(".ado-aw") + .join("imports") + .join("acme") + .join("shared") + .join(SHA) + .join("components_deploy.md"); + assert!(cache_file.exists()); + assert_eq!(fs::read(&cache_file).unwrap(), manifest()); + let attributes = fs::read_to_string( + repo.path() + .join(".ado-aw") + .join("imports") + .join(".gitattributes"), + ) + .unwrap(); + assert_eq!(attributes, IMPORT_GITATTRIBUTES); + assert_eq!( + resolved[0].provenance.source, + "acme/shared/components/deploy.md" + ); + assert_eq!(resolved[0].provenance.sha.as_deref(), Some(SHA)); + assert_eq!( + resolved[0].provenance.manifest_digest, + sha256_hex(manifest()) + ); + } + + #[test] + fn remote_import_uses_cache_before_fetching() { + let repo = tempfile::tempdir().unwrap(); + let entry = import_entry(&format!("acme/shared/components/deploy.md@{SHA}")); + let cache_dir = repo + .path() + .join(".ado-aw") + .join("imports") + .join("acme") + .join("shared") + .join(SHA); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write(cache_dir.join("components_deploy.md"), manifest()).unwrap(); + + let resolved = + resolve_imports_with_repo_root(&[entry], repo.path(), repo.path(), &PanicFetcher) + .unwrap(); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].body, "# Imported\nBody"); + } + + #[test] + fn size_cap_rejects_large_manifest() { + let repo = tempfile::tempdir().unwrap(); + let entry = import_entry(&format!("acme/shared/component.md@{SHA}")); + let fetcher = StaticFetcher { + bytes: vec![b'x'; MAX_MANIFEST_BYTES + 1], + }; + + let err = resolve_imports_with_repo_root(&[entry], repo.path(), repo.path(), &fetcher) + .unwrap_err(); + + assert!(format!("{err:#}").contains("exceeding the 262144 byte limit")); + } + + #[test] + fn section_selector_extracts_only_that_section() { + let repo = tempfile::tempdir().unwrap(); + let workflow_dir = repo.path(); + fs::write( + workflow_dir.join("component.md"), + b"---\n{}\n---\n# One\none\n## Two\ntwo\n### Detail\nkeep\n## Three\nthree\n", + ) + .unwrap(); + + let resolved = resolve_imports_with_repo_root( + &[import_entry("component.md#Two")], + workflow_dir, + repo.path(), + &PanicFetcher, + ) + .unwrap(); + + assert_eq!(resolved[0].body, "## Two\ntwo\n### Detail\nkeep"); + } + + #[test] + fn optional_missing_local_import_is_skipped_and_required_missing_errors() { + let repo = tempfile::tempdir().unwrap(); + + let optional = resolve_imports_with_repo_root( + &[import_entry("missing.md?")], + repo.path(), + repo.path(), + &PanicFetcher, + ) + .unwrap(); + assert!(optional.is_empty()); + + let err = resolve_imports_with_repo_root( + &[import_entry("missing.md")], + repo.path(), + repo.path(), + &PanicFetcher, + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("failed to resolve import `missing.md`") + ); + } + + #[test] + fn imports_per_workflow_limit_is_enforced() { + let repo = tempfile::tempdir().unwrap(); + let entries: Vec = (0..=MAX_IMPORTS_PER_WORKFLOW) + .map(|idx| import_entry(&format!("component-{idx}.md?"))) + .collect(); + + let err = resolve_imports_with_repo_root(&entries, repo.path(), repo.path(), &PanicFetcher) + .unwrap_err(); + + assert!( + err.to_string() + .contains("imports per workflow must be <= 20") + ); + } +} diff --git a/src/compile/imports/schema.rs b/src/compile/imports/schema.rs new file mode 100644 index 00000000..f69beca7 --- /dev/null +++ b/src/compile/imports/schema.rs @@ -0,0 +1,721 @@ +//! `import-schema` modeling, consumer-`with` validation, and +//! `${{ ado.aw.import-inputs. }}` substitution. +#![allow(dead_code)] + +use std::collections::BTreeMap; + +use anyhow::{Context, Result}; +use serde_json::{Map as JsonMap, Value as JsonValue}; +use serde_yaml::{Mapping as YamlMapping, Value as YamlValue}; + +const IMPORT_SCHEMA_KEY: &str = "import-schema"; +const PLACEHOLDER_PREFIX: &str = "ado.aw.import-inputs."; + +#[derive(Debug, Clone, PartialEq)] +pub struct ImportSchema { + pub fields: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SchemaField { + pub ty: SchemaType, + pub required: bool, + pub default: Option, + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SchemaType { + String, + Number, + Boolean, + Choice(Vec), + Array(Box), + Object(BTreeMap), +} + +/// Parses a component's `import-schema:` front-matter block. +/// +/// Components without `import-schema:` return an empty schema. +pub fn parse_import_schema(front_matter: &YamlValue) -> Result { + let Some(schema_value) = mapping_get(front_matter, IMPORT_SCHEMA_KEY) else { + return Ok(ImportSchema { + fields: BTreeMap::new(), + }); + }; + + let schema_map = yaml_mapping(schema_value, IMPORT_SCHEMA_KEY)?; + Ok(ImportSchema { + fields: parse_fields(schema_map, IMPORT_SCHEMA_KEY, 0)?, + }) +} + +/// Validates consumer-provided `with:` values against an import schema. +/// +/// The returned map includes validated provided values plus defaults for absent +/// fields that define `default:`. +pub fn validate_with( + schema: &ImportSchema, + with: &JsonMap, +) -> Result> { + validate_fields(&schema.fields, with, "") +} + +/// Substitutes `${{ ado.aw.import-inputs. }}` placeholders in text. +/// +/// Whitespace around the expression inside `${{ ... }}` is allowed. Dotted +/// paths (for example `config.apiKey`) access object sub-fields. Missing keys +/// are intentionally left unchanged so a later validation pass can flag them. +pub fn substitute_inputs(text: &str, inputs: &JsonMap) -> String { + let mut output = String::with_capacity(text.len()); + let mut cursor = 0; + + while let Some(relative_start) = text[cursor..].find("${{") { + let start = cursor + relative_start; + output.push_str(&text[cursor..start]); + + let expression_start = start + 3; + let Some(relative_end) = text[expression_start..].find("}}") else { + output.push_str(&text[start..]); + return output; + }; + let expression_end = expression_start + relative_end; + let original = &text[start..expression_end + 2]; + let expression = text[expression_start..expression_end].trim(); + + match expression.strip_prefix(PLACEHOLDER_PREFIX) { + Some(path) if !path.is_empty() => match lookup_input_path(inputs, path) { + Some(value) => output.push_str(&render_json_value(value)), + None => output.push_str(original), + }, + _ => output.push_str(original), + } + + cursor = expression_end + 2; + } + + output.push_str(&text[cursor..]); + output +} + +/// Walks front matter and substitutes import-input placeholders in every string +/// scalar. +pub fn substitute_front_matter(fm: &YamlValue, inputs: &JsonMap) -> YamlValue { + match fm { + YamlValue::String(s) => YamlValue::String(substitute_inputs(s, inputs)), + YamlValue::Sequence(items) => YamlValue::Sequence( + items + .iter() + .map(|item| substitute_front_matter(item, inputs)) + .collect(), + ), + YamlValue::Mapping(mapping) => { + let mut substituted = YamlMapping::new(); + for (key, value) in mapping { + substituted.insert( + substitute_front_matter(key, inputs), + substitute_front_matter(value, inputs), + ); + } + YamlValue::Mapping(substituted) + } + other => other.clone(), + } +} + +/// Parses, validates, defaults, substitutes, and consumes `import-schema:`. +/// +/// This is intentionally a pure transformation: it does not mutate a +/// `ResolvedImport` and does not merge the component into the consumer +/// workflow. +pub fn apply_import_inputs( + front_matter: &YamlValue, + body: &str, + with: &JsonMap, +) -> Result<(YamlValue, String)> { + let schema = parse_import_schema(front_matter)?; + let inputs = validate_with(&schema, with)?; + let stripped_front_matter = strip_import_schema(front_matter); + + Ok(( + substitute_front_matter(&stripped_front_matter, &inputs), + substitute_inputs(body, &inputs), + )) +} + +fn parse_fields( + fields_map: &YamlMapping, + path: &str, + object_depth: usize, +) -> Result> { + let mut fields = BTreeMap::new(); + for (key, value) in fields_map { + let field_name = yaml_string(key, path)?; + let field_path = dotted_path(path, field_name); + if fields + .insert( + field_name.to_string(), + parse_schema_field(value, &field_path, object_depth)?, + ) + .is_some() + { + anyhow::bail!("duplicate import-schema field `{field_path}`"); + } + } + Ok(fields) +} + +fn parse_schema_field(value: &YamlValue, path: &str, object_depth: usize) -> Result { + let field_map = yaml_mapping(value, path)?; + let ty = parse_schema_type(field_map, path, object_depth)?; + let required = match mapping_get_in(field_map, "required") { + Some(YamlValue::Bool(required)) => *required, + Some(_) => anyhow::bail!("import-schema field `{path}.required` must be a boolean"), + None => false, + }; + let default = mapping_get_in(field_map, "default") + .map(|value| yaml_to_json(value, &dotted_path(path, "default"))) + .transpose()?; + let description = match mapping_get_in(field_map, "description") { + Some(YamlValue::String(description)) => Some(description.clone()), + Some(_) => anyhow::bail!("import-schema field `{path}.description` must be a string"), + None => None, + }; + + Ok(SchemaField { + ty, + required, + default, + description, + }) +} + +fn parse_schema_type( + field_map: &YamlMapping, + path: &str, + object_depth: usize, +) -> Result { + let ty_value = mapping_get_in(field_map, "type") + .ok_or_else(|| anyhow::anyhow!("import-schema field `{path}` is missing `type`"))?; + let ty = yaml_string(ty_value, &dotted_path(path, "type"))?; + + match ty { + "string" => Ok(SchemaType::String), + "number" => Ok(SchemaType::Number), + "boolean" => Ok(SchemaType::Boolean), + "choice" => parse_choice_type(field_map, path), + "array" => parse_array_type(field_map, path, object_depth), + "object" => parse_object_type(field_map, path, object_depth), + other => anyhow::bail!("import-schema field `{path}.type` has unsupported type `{other}`"), + } +} + +fn parse_choice_type(field_map: &YamlMapping, path: &str) -> Result { + let options_value = mapping_get_in(field_map, "options").ok_or_else(|| { + anyhow::anyhow!("choice import-schema field `{path}` is missing `options`") + })?; + let options_sequence = yaml_sequence(options_value, &dotted_path(path, "options"))?; + let mut options = Vec::with_capacity(options_sequence.len()); + for (index, option) in options_sequence.iter().enumerate() { + options.push(yaml_string(option, &format!("{}.options[{index}]", path))?.to_string()); + } + Ok(SchemaType::Choice(options)) +} + +fn parse_array_type( + field_map: &YamlMapping, + path: &str, + object_depth: usize, +) -> Result { + let items_value = mapping_get_in(field_map, "items") + .ok_or_else(|| anyhow::anyhow!("array import-schema field `{path}` is missing `items`"))?; + let items_map = yaml_mapping(items_value, &dotted_path(path, "items"))?; + Ok(SchemaType::Array(Box::new(parse_schema_type( + items_map, + &dotted_path(path, "items"), + object_depth, + )?))) +} + +fn parse_object_type( + field_map: &YamlMapping, + path: &str, + object_depth: usize, +) -> Result { + if object_depth > 0 { + anyhow::bail!( + "nested object import-schema field `{path}` is not supported; object properties are one level deep" + ); + } + let properties_value = mapping_get_in(field_map, "properties").ok_or_else(|| { + anyhow::anyhow!("object import-schema field `{path}` is missing `properties`") + })?; + let properties_map = yaml_mapping(properties_value, &dotted_path(path, "properties"))?; + Ok(SchemaType::Object(parse_fields( + properties_map, + &dotted_path(path, "properties"), + object_depth + 1, + )?)) +} + +fn validate_fields( + fields: &BTreeMap, + with: &JsonMap, + path_prefix: &str, +) -> Result> { + for key in with.keys() { + if !fields.contains_key(key) { + anyhow::bail!("unknown import input `{}`", dotted_path(path_prefix, key)); + } + } + + let mut effective = JsonMap::new(); + for (name, field) in fields { + let path = dotted_path(path_prefix, name); + match with.get(name) { + Some(value) => { + effective.insert(name.clone(), validate_value(&field.ty, value, &path)?); + } + None if field.default.is_some() => { + let default = field.default.as_ref().expect("checked is_some"); + effective.insert(name.clone(), validate_value(&field.ty, default, &path)?); + } + None if field.required => { + anyhow::bail!("missing required import input `{path}`"); + } + None => {} + } + } + Ok(effective) +} + +fn validate_value(ty: &SchemaType, value: &JsonValue, path: &str) -> Result { + match ty { + SchemaType::String => match value { + JsonValue::String(_) => Ok(value.clone()), + _ => type_error(path, "string", value), + }, + SchemaType::Number => { + if value.is_number() { + Ok(value.clone()) + } else { + type_error(path, "number", value) + } + } + SchemaType::Boolean => match value { + JsonValue::Bool(_) => Ok(value.clone()), + _ => type_error(path, "boolean", value), + }, + SchemaType::Choice(options) => match value { + JsonValue::String(value) if options.contains(value) => { + Ok(JsonValue::String(value.clone())) + } + JsonValue::String(value) => anyhow::bail!( + "import input `{path}` value `{value}` is not one of: {}", + options.join(", ") + ), + _ => type_error(path, "choice string", value), + }, + SchemaType::Array(item_ty) => match value { + JsonValue::Array(items) => { + let mut validated = Vec::with_capacity(items.len()); + for (index, item) in items.iter().enumerate() { + validated.push(validate_value(item_ty, item, &format!("{path}[{index}]"))?); + } + Ok(JsonValue::Array(validated)) + } + _ => type_error(path, "array", value), + }, + SchemaType::Object(properties) => match value { + JsonValue::Object(object) => Ok(JsonValue::Object(validate_fields( + properties, object, path, + )?)), + _ => type_error(path, "object", value), + }, + } +} + +fn type_error(path: &str, expected: &str, value: &JsonValue) -> Result { + anyhow::bail!( + "import input `{path}` must be {expected}, got {}", + json_value_kind(value) + ) +} + +fn strip_import_schema(front_matter: &YamlValue) -> YamlValue { + let YamlValue::Mapping(mapping) = front_matter else { + return front_matter.clone(); + }; + + let mut stripped = YamlMapping::new(); + for (key, value) in mapping { + if matches!(key, YamlValue::String(key) if key == IMPORT_SCHEMA_KEY) { + continue; + } + stripped.insert(key.clone(), value.clone()); + } + YamlValue::Mapping(stripped) +} + +fn mapping_get<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlValue> { + let YamlValue::Mapping(mapping) = value else { + return None; + }; + mapping_get_in(mapping, key) +} + +fn mapping_get_in<'a>(mapping: &'a YamlMapping, key: &str) -> Option<&'a YamlValue> { + mapping.iter().find_map(|(mapping_key, value)| { + if matches!(mapping_key, YamlValue::String(mapping_key) if mapping_key == key) { + Some(value) + } else { + None + } + }) +} + +fn yaml_mapping<'a>(value: &'a YamlValue, path: &str) -> Result<&'a YamlMapping> { + match value { + YamlValue::Mapping(mapping) => Ok(mapping), + _ => anyhow::bail!( + "import-schema field `{path}` must be a mapping, got {}", + yaml_value_kind(value) + ), + } +} + +fn yaml_sequence<'a>(value: &'a YamlValue, path: &str) -> Result<&'a Vec> { + match value { + YamlValue::Sequence(sequence) => Ok(sequence), + _ => anyhow::bail!( + "import-schema field `{path}` must be a sequence, got {}", + yaml_value_kind(value) + ), + } +} + +fn yaml_string<'a>(value: &'a YamlValue, path: &str) -> Result<&'a str> { + match value { + YamlValue::String(value) => Ok(value), + _ => anyhow::bail!( + "import-schema field `{path}` must be a string, got {}", + yaml_value_kind(value) + ), + } +} + +fn yaml_to_json(value: &YamlValue, path: &str) -> Result { + serde_json::to_value(value) + .with_context(|| format!("import-schema field `{path}` default is not JSON-compatible")) +} + +fn lookup_input_path<'a>( + inputs: &'a JsonMap, + path: &str, +) -> Option<&'a JsonValue> { + let mut parts = path.split('.'); + let first = parts.next()?; + if first.is_empty() { + return None; + } + + let mut value = inputs.get(first)?; + for part in parts { + if part.is_empty() { + return None; + } + value = value.as_object()?.get(part)?; + } + Some(value) +} + +fn render_json_value(value: &JsonValue) -> String { + match value { + JsonValue::String(value) => value.clone(), + JsonValue::Number(value) => value.to_string(), + JsonValue::Bool(value) => value.to_string(), + JsonValue::Array(_) | JsonValue::Object(_) => { + serde_json::to_string(value).unwrap_or_else(|_| value.to_string()) + } + JsonValue::Null => "null".to_string(), + } +} + +fn dotted_path(prefix: &str, key: &str) -> String { + if prefix.is_empty() { + key.to_string() + } else { + format!("{prefix}.{key}") + } +} + +fn yaml_value_kind(value: &YamlValue) -> &'static str { + match value { + YamlValue::Null => "null", + YamlValue::Bool(_) => "boolean", + YamlValue::Number(_) => "number", + YamlValue::String(_) => "string", + YamlValue::Sequence(_) => "sequence/array", + YamlValue::Mapping(_) => "mapping/object", + YamlValue::Tagged(_) => "tagged value", + } +} + +fn json_value_kind(value: &JsonValue) -> &'static str { + match value { + JsonValue::Null => "null", + JsonValue::Bool(_) => "boolean", + JsonValue::Number(_) => "number", + JsonValue::String(_) => "string", + JsonValue::Array(_) => "array", + JsonValue::Object(_) => "object", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn yaml(input: &str) -> YamlValue { + serde_yaml::from_str(input).expect("valid yaml") + } + + fn schema_yaml() -> YamlValue { + yaml( + r#" +import-schema: + name: + type: string + required: true + description: Component name + count: + type: number + default: 3 + enabled: + type: boolean + default: true + mode: + type: choice + options: [fast, slow] + tags: + type: array + items: + type: string + config: + type: object + properties: + apiKey: + type: string + required: true + retries: + type: number + default: 2 +"#, + ) + } + + #[test] + fn parse_import_schema_supports_all_types_required_default_and_description() { + let schema = parse_import_schema(&schema_yaml()).unwrap(); + + assert!(matches!(schema.fields["name"].ty, SchemaType::String)); + assert!(schema.fields["name"].required); + assert_eq!( + schema.fields["name"].description.as_deref(), + Some("Component name") + ); + assert!(matches!(schema.fields["count"].ty, SchemaType::Number)); + assert_eq!(schema.fields["count"].default, Some(json!(3))); + assert!(matches!(schema.fields["enabled"].ty, SchemaType::Boolean)); + assert_eq!(schema.fields["enabled"].default, Some(json!(true))); + assert_eq!( + schema.fields["mode"].ty, + SchemaType::Choice(vec!["fast".to_string(), "slow".to_string()]) + ); + assert_eq!( + schema.fields["tags"].ty, + SchemaType::Array(Box::new(SchemaType::String)) + ); + match &schema.fields["config"].ty { + SchemaType::Object(properties) => { + assert!(matches!(properties["apiKey"].ty, SchemaType::String)); + assert!(properties["apiKey"].required); + assert_eq!(properties["retries"].default, Some(json!(2))); + } + other => panic!("expected object schema, got {other:?}"), + } + } + + #[test] + fn parse_import_schema_returns_empty_when_missing() { + let schema = parse_import_schema(&yaml("name: example\n")).unwrap(); + + assert!(schema.fields.is_empty()); + } + + #[test] + fn validate_with_fills_defaults_and_object_property_defaults() { + let schema = parse_import_schema(&schema_yaml()).unwrap(); + let with = json!({ + "name": "demo", + "mode": "fast", + "tags": ["a", "b"], + "config": { "apiKey": "secret" } + }); + let validated = validate_with(&schema, with.as_object().unwrap()).unwrap(); + + assert_eq!(validated["name"], json!("demo")); + assert_eq!(validated["count"], json!(3)); + assert_eq!(validated["enabled"], json!(true)); + assert_eq!(validated["tags"], json!(["a", "b"])); + assert_eq!( + validated["config"], + json!({ "apiKey": "secret", "retries": 2 }) + ); + } + + #[test] + fn validate_with_errors_for_missing_required() { + let schema = parse_import_schema(&schema_yaml()).unwrap(); + let err = validate_with(&schema, &JsonMap::new()).unwrap_err(); + + assert!( + err.to_string() + .contains("missing required import input `name`") + ); + } + + #[test] + fn validate_with_errors_for_unknown_key() { + let schema = parse_import_schema(&schema_yaml()).unwrap(); + let with = json!({ "name": "demo", "unknown": true }); + let err = validate_with(&schema, with.as_object().unwrap()).unwrap_err(); + + assert!(err.to_string().contains("unknown import input `unknown`")); + } + + #[test] + fn validate_with_errors_for_choice_not_in_options() { + let schema = parse_import_schema(&schema_yaml()).unwrap(); + let with = json!({ "name": "demo", "mode": "medium" }); + let err = validate_with(&schema, with.as_object().unwrap()).unwrap_err(); + + assert!(err.to_string().contains("mode")); + assert!(err.to_string().contains("fast, slow")); + } + + #[test] + fn validate_with_errors_for_array_element_type_mismatch() { + let schema = parse_import_schema(&schema_yaml()).unwrap(); + let with = json!({ "name": "demo", "tags": ["ok", 1] }); + let err = validate_with(&schema, with.as_object().unwrap()).unwrap_err(); + + assert!(err.to_string().contains("tags[1]")); + assert!(err.to_string().contains("string")); + } + + #[test] + fn substitute_inputs_supports_scalars_dotted_paths_json_values_and_missing_passthrough() { + let inputs = json!({ + "name": "demo", + "count": 7, + "enabled": false, + "tags": ["a", "b"], + "config": { "apiKey": "secret" } + }); + let text = concat!( + "name=${{ado.aw.import-inputs.name}} ", + "key=${{ ado.aw.import-inputs.config.apiKey }} ", + "count=${{ ado.aw.import-inputs.count }} ", + "enabled=${{ ado.aw.import-inputs.enabled }} ", + "tags=${{ ado.aw.import-inputs.tags }} ", + "config=${{ ado.aw.import-inputs.config }} ", + "missing=${{ ado.aw.import-inputs.missing }}" + ); + + let substituted = substitute_inputs(text, inputs.as_object().unwrap()); + + assert_eq!( + substituted, + concat!( + "name=demo ", + "key=secret ", + "count=7 ", + "enabled=false ", + "tags=[\"a\",\"b\"] ", + "config={\"apiKey\":\"secret\"} ", + "missing=${{ ado.aw.import-inputs.missing }}" + ) + ); + } + + #[test] + fn substitute_front_matter_walks_nested_mappings_and_sequences() { + let fm = yaml( + r#" +name: ${{ ado.aw.import-inputs.name }} +steps: + - bash: echo ${{ ado.aw.import-inputs.config.apiKey }} +nested: + value: before ${{ado.aw.import-inputs.name}} after +"#, + ); + let inputs = json!({ + "name": "demo", + "config": { "apiKey": "secret" } + }); + + let substituted = substitute_front_matter(&fm, inputs.as_object().unwrap()); + + assert_eq!(mapping_get(&substituted, "name"), Some(&yaml("demo"))); + let steps = mapping_get(&substituted, "steps") + .unwrap() + .as_sequence() + .unwrap(); + assert_eq!(mapping_get(&steps[0], "bash"), Some(&yaml("echo secret"))); + let nested = mapping_get(&substituted, "nested").unwrap(); + assert_eq!( + mapping_get(nested, "value"), + Some(&yaml("before demo after")) + ); + } + + #[test] + fn apply_import_inputs_strips_schema_and_substitutes_front_matter_and_body() { + let fm = yaml( + r#" +import-schema: + name: + type: string + required: true + count: + type: number + default: 2 +name: component-${{ ado.aw.import-inputs.name }} +variables: + count: "${{ ado.aw.import-inputs.count }}" +"#, + ); + let with = json!({ "name": "demo" }); + + let (front_matter, body) = apply_import_inputs( + &fm, + "Hello ${{ ado.aw.import-inputs.name }} ${{ ado.aw.import-inputs.count }}", + with.as_object().unwrap(), + ) + .unwrap(); + + assert!(mapping_get(&front_matter, IMPORT_SCHEMA_KEY).is_none()); + assert_eq!( + mapping_get(&front_matter, "name"), + Some(&yaml("component-demo")) + ); + let variables = mapping_get(&front_matter, "variables").unwrap(); + assert_eq!( + mapping_get(variables, "count"), + Some(&YamlValue::String("2".to_string())) + ); + assert_eq!(body, "Hello demo 2"); + } +} diff --git a/src/compile/ir/lower.rs b/src/compile/ir/lower.rs index 454dad50..414e3e98 100644 --- a/src/compile/ir/lower.rs +++ b/src/compile/ir/lower.rs @@ -355,6 +355,7 @@ fn lower_repository_resource(r: &RepositoryResource) -> Value { kind, name, r#ref, + endpoint, } => { m.insert(s("repository"), s(identifier)); m.insert(s("type"), s(kind)); @@ -362,6 +363,9 @@ fn lower_repository_resource(r: &RepositoryResource) -> Value { if let Some(r) = r#ref { m.insert(s("ref"), s(r)); } + if let Some(ep) = endpoint { + m.insert(s("endpoint"), s(ep)); + } } } Value::Mapping(m) @@ -1422,6 +1426,33 @@ mod tests { } } + #[test] + fn lower_named_repository_emits_endpoint_when_present() { + let r = RepositoryResource::Named { + identifier: "shared".to_string(), + kind: "github".to_string(), + name: "acme/shared".to_string(), + r#ref: Some("refs/heads/main".to_string()), + endpoint: Some("shared-conn".to_string()), + }; + let v = lower_repository_resource(&r); + assert_eq!(v["endpoint"], Value::String("shared-conn".to_string())); + assert_eq!(v["type"], Value::String("github".to_string())); + } + + #[test] + fn lower_named_repository_omits_endpoint_when_absent() { + let r = RepositoryResource::Named { + identifier: "shared".to_string(), + kind: "git".to_string(), + name: "proj/shared".to_string(), + r#ref: Some("refs/heads/main".to_string()), + endpoint: None, + }; + let v = lower_repository_resource(&r); + assert!(v.get("endpoint").is_none()); + } + #[test] fn lower_env_value_runtime_expression_emits_hoisted_macro() { let g = Graph::default(); @@ -1612,7 +1643,10 @@ mod tests { .and_then(|v| v.as_mapping()) .expect("lowered job should contain pool mapping"); - assert_eq!(pool.get(s("name")).and_then(|v| v.as_str()), Some("CustomPool")); + assert_eq!( + pool.get(s("name")).and_then(|v| v.as_str()), + Some("CustomPool") + ); let demands: Vec<&str> = pool .get(s("demands")) .and_then(|v| v.as_sequence()) diff --git a/src/compile/ir/mod.rs b/src/compile/ir/mod.rs index 4c826e33..6bf1a298 100644 --- a/src/compile/ir/mod.rs +++ b/src/compile/ir/mod.rs @@ -251,6 +251,7 @@ pub enum RepositoryResource { kind: String, name: String, r#ref: Option, + endpoint: Option, }, } diff --git a/src/compile/mod.rs b/src/compile/mod.rs index bc522ee8..f1f5b9de 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -13,16 +13,18 @@ pub(crate) mod agentic_pipeline; #[cfg(test)] mod codemod_integration_test; pub(crate) mod codemods; +pub mod custom_tools; pub mod extensions; pub(crate) mod filter_ir; mod gitattributes; +pub mod imports; pub(crate) mod ir; mod job; mod job_ir; mod onees; mod onees_ir; -pub(crate) mod pr_filters; mod path_layout_check; +pub(crate) mod pr_filters; pub mod source_path_guard; mod stage; mod stage_ir; @@ -142,13 +144,36 @@ async fn compile_pipeline_inner( let parsed = common::parse_markdown_detailed_with_registry(&content, registry)?; let mut front_matter = parsed.front_matter; - let markdown_body = parsed.markdown_body; + let mut markdown_body = parsed.markdown_body; let codemod_report = parsed.codemods; let front_matter_mapping = parsed.front_matter_mapping; let leading_whitespace = parsed.leading_whitespace; let body_raw = parsed.body_raw; let source_sha256 = parsed.source_sha256; + // Resolve and merge cross-repository / local `imports:` (D8/D9). Runs only + // when the workflow declares imports, so import-free workflows are + // unaffected. The merge is applied to a CLONE of the front-matter mapping — + // the original `front_matter_mapping` is preserved untouched so that any + // codemod source-rewrite keeps the author's `imports:` in their file rather + // than writing the expanded/merged form back to disk. + if !front_matter.imports.is_empty() { + let base_dir = input_path.parent().unwrap_or_else(|| Path::new(".")); + let repo_root = find_repo_root(base_dir).unwrap_or_else(|| base_dir.to_path_buf()); + let fetcher = crate::compile::imports::GhCliFetcher; + let mut merged_mapping = front_matter_mapping.clone(); + markdown_body = crate::compile::imports::merge::merge_imports( + &mut merged_mapping, + &markdown_body, + &front_matter.imports, + base_dir, + &repo_root, + &fetcher, + )?; + front_matter = serde_yaml::from_value(serde_yaml::Value::Mapping(merged_mapping)) + .context("Failed to parse front matter after merging imports")?; + } + // Sanitize all front matter text fields before any further processing. // This neutralizes pipeline command injection (##vso[), strips control // characters, and enforces content limits across all config values. @@ -173,9 +198,7 @@ async fn compile_pipeline_inner( // Checkout-aware path-layout advisories (warning-only): surface // hand-written paths that won't exist under the resolved checkout // layout, plus deprecated directory markers left in the agent body. - for warning in - path_layout_check::collect_path_layout_warnings(&front_matter, &markdown_body) - { + for warning in path_layout_check::collect_path_layout_warnings(&front_matter, &markdown_body) { eprintln!("Warning: {warning}"); } diff --git a/src/compile/onees_ir.rs b/src/compile/onees_ir.rs index d37b67a2..06f6ca2f 100644 --- a/src/compile/onees_ir.rs +++ b/src/compile/onees_ir.rs @@ -116,6 +116,7 @@ pub fn build_onees_pipeline( kind: ONEES_TEMPLATES_REPO_KIND.to_string(), name: ONEES_TEMPLATES_REPO_NAME.to_string(), r#ref: Some(ONEES_TEMPLATES_REPO_REF.to_string()), + endpoint: None, }, ); diff --git a/src/compile/types.rs b/src/compile/types.rs index 110038cc..0f216490 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -1088,6 +1088,11 @@ pub struct FrontMatter { /// MCP server configurations #[serde(default, rename = "mcp-servers")] pub mcp_servers: HashMap, + /// Reusable workflow imports. Entries may be local paths or SHA-pinned + /// cross-repository specs, with optional import-schema inputs. + #[allow(dead_code)] + #[serde(default)] + pub imports: Vec, /// Per-tool configuration for safe outputs #[serde(default, rename = "safe-outputs")] pub safe_outputs: HashMap, @@ -1187,11 +1192,183 @@ impl FrontMatter { } } +/// A single `imports:` entry — either a bare spec string or an object form. +#[derive(Debug, Clone, PartialEq)] +pub struct ImportEntry { + /// The import spec string (`uses:` in object form). + pub uses: String, + /// Non-secret input values for the imported workflow schema. + pub with: serde_json::Map, + /// Optional ADO service connection for GitHub/GHE remote sources. + pub endpoint: Option, +} + +impl<'de> Deserialize<'de> for ImportEntry { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + use serde::de; + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct ImportEntryObject { + uses: String, + #[serde(default)] + with: serde_json::Map, + #[serde(default)] + endpoint: Option, + } + + struct ImportEntryVisitor; + + impl<'de> de::Visitor<'de> for ImportEntryVisitor { + type Value = ImportEntry; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str( + "a string import spec or an object with `uses`, optional `with`, and optional `endpoint`", + ) + } + + fn visit_str(self, value: &str) -> std::result::Result { + Ok(ImportEntry { + uses: value.to_string(), + with: serde_json::Map::new(), + endpoint: None, + }) + } + + fn visit_map(self, map: M) -> std::result::Result + where + M: de::MapAccess<'de>, + { + let entry = + ImportEntryObject::deserialize(de::value::MapAccessDeserializer::new(map))?; + Ok(ImportEntry { + uses: entry.uses, + with: entry.with, + endpoint: entry.endpoint, + }) + } + } + + deserializer.deserialize_any(ImportEntryVisitor) + } +} + +/// Parsed import source. The simple local-vs-remote heuristic is: +/// after removing a trailing optional marker (`?`) and section (`#Section`), +/// any spec containing `@` is remote and must be `owner/repo/path@`; +/// all specs without `@` are treated as local paths for later resolution. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub enum ImportSource { + /// Local import path within the current repository. + Local { + /// Relative path to the imported markdown file. + path: String, + /// Optional markdown section selector. + section: Option, + /// Whether a missing import should be tolerated by later resolution. + optional: bool, + }, + /// Cross-repository import pinned to a full commit SHA. + Remote(ParsedImportSpec), +} + +/// Parsed cross-repository import spec. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub struct ParsedImportSpec { + /// Repository owner/organization. + pub owner: String, + /// Repository name. + pub repo: String, + /// Path within the repository. + pub path: String, + /// Full 40-character commit SHA pin. + pub sha: crate::secure::CommitSha, + /// Optional markdown section selector. + pub section: Option, + /// Whether a missing import should be tolerated by later resolution. + pub optional: bool, +} + +impl ImportEntry { + /// Parse and validate the import spec string. + #[allow(dead_code)] + pub fn parse_source(&self) -> anyhow::Result { + let raw = self.uses.trim(); + if raw.is_empty() { + anyhow::bail!("import spec must not be empty"); + } + + let (without_optional, optional) = match raw.strip_suffix('?') { + Some(value) => (value, true), + None => (raw, false), + }; + + let (base, section) = match without_optional.split_once('#') { + Some((base, section)) => { + if section.is_empty() { + anyhow::bail!("import section must not be empty"); + } + (base, Some(section.to_string())) + } + None => (without_optional, None), + }; + + if base.is_empty() { + anyhow::bail!("import path must not be empty"); + } + + if !base.contains('@') { + return Ok(ImportSource::Local { + path: base.to_string(), + section, + optional, + }); + } + + let (repo_and_path, ref_part) = base + .split_once('@') + .ok_or_else(|| anyhow::anyhow!("remote import spec must contain `@`"))?; + let sha = + crate::secure::CommitSha::parse(ref_part.to_string()).map_err(|_| { + anyhow::anyhow!( + "cross-repository imports must be pinned to a full 40-character commit SHA, got '{}'", + ref_part + ) + })?; + + let mut parts = repo_and_path.splitn(3, '/'); + let owner = parts.next().unwrap_or_default(); + let repo = parts.next().unwrap_or_default(); + let path = parts.next().unwrap_or_default(); + if owner.is_empty() || repo.is_empty() || path.is_empty() { + anyhow::bail!( + "remote import spec must be `owner/repo/path@<40-character-sha>`, got '{}'", + raw + ); + } + + Ok(ImportSource::Remote(ParsedImportSpec { + owner: owner.to_string(), + repo: repo.to_string(), + path: path.to_string(), + sha, + section, + optional, + })) + } +} + /// Reserved keys inside the `safe-outputs:` map that configure the section /// itself rather than naming a safe-output tool. These must never be treated /// as tool names (e.g. in `--enabled-tools`, Stage-3 budgets, or unknown-key /// validation). -pub const SAFE_OUTPUT_RESERVED_KEYS: &[&str] = &["require-approval"]; +pub const SAFE_OUTPUT_RESERVED_KEYS: &[&str] = &["require-approval", "scripts", "jobs"]; /// Automatic action a manual-validation gate takes when its pending period /// elapses with no human response. Mirrors `ManualValidation@1`'s `onTimeout`. @@ -1269,6 +1446,38 @@ impl FrontMatter { .filter(|k| !SAFE_OUTPUT_RESERVED_KEYS.contains(&k.as_str())) } + /// Names of **custom** safe-output tools declared by imported components + /// under the `safe-outputs.scripts.` (entrypoint) and + /// `safe-outputs.jobs.` (arbitrary-ADO-steps) sections (decision + /// D16). These are agent-callable tools generated from imported manifests, + /// distinct from the built-in tools keyed at the top level. + pub fn custom_safe_output_tool_names(&self) -> Vec { + let mut names = Vec::new(); + for section in ["scripts", "jobs"] { + if let Some(map) = self.safe_outputs.get(section).and_then(|v| v.as_object()) { + names.extend(map.keys().cloned()); + } + } + names + } + + /// The full set of safe-output tool names for approval-partitioning and + /// emission: built-in tools (top-level keys) plus custom tools + /// (`scripts`/`jobs`). A top-level key that merely *configures* a custom + /// tool (same name) is folded into that custom tool rather than counted as a + /// separate built-in. + pub fn all_safe_output_tool_names(&self) -> Vec { + let custom: std::collections::HashSet = + self.custom_safe_output_tool_names().into_iter().collect(); + let mut names: Vec = self + .safe_output_tool_names() + .filter(|k| !custom.contains(*k)) + .cloned() + .collect(); + names.extend(custom); + names + } + /// Whether the workflow enables **any** safe-output tool. /// /// Single source of truth for the safe-outputs-summary feature gate: it @@ -1281,6 +1490,7 @@ impl FrontMatter { /// that was never downloaded. pub fn has_any_safe_output_tool(&self) -> bool { self.safe_output_tool_names().next().is_some() + || !self.custom_safe_output_tool_names().is_empty() } /// The parsed, sanitized `create-pull-request` config, or `None` when the @@ -1357,15 +1567,17 @@ impl FrontMatter { /// Partition enabled safe-output tool names into `(auto, reviewed)` where /// `reviewed` tools require manual approval and `auto` tools do not. Both - /// lists are sorted for deterministic emission. + /// lists are sorted for deterministic emission. Includes both built-in and + /// custom (`scripts`/`jobs`) tools; a custom tool's approval setting is read + /// from its top-level per-tool config key (or the section-level default). pub fn partition_safe_outputs_by_approval(&self) -> (Vec, Vec) { let mut auto = Vec::new(); let mut reviewed = Vec::new(); - for tool in self.safe_output_tool_names() { - if self.tool_requires_approval(tool).is_some() { - reviewed.push(tool.clone()); + for tool in self.all_safe_output_tool_names() { + if self.tool_requires_approval(&tool).is_some() { + reviewed.push(tool); } else { - auto.push(tool.clone()); + auto.push(tool); } } auto.sort(); @@ -1427,7 +1639,11 @@ impl FrontMatter { check("safe-outputs.require-approval", v)?; } for tool in self.safe_output_tool_names() { - if let Some(v) = self.safe_outputs.get(tool).and_then(|c| c.get("require-approval")) { + if let Some(v) = self + .safe_outputs + .get(tool) + .and_then(|c| c.get("require-approval")) + { check(&format!("safe-outputs.{tool}.require-approval"), v)?; } } @@ -1801,6 +2017,8 @@ pub struct Repository { #[serde(default = "default_ref")] #[serde(rename = "ref")] pub repo_ref: String, + #[serde(default)] + pub endpoint: Option, } fn default_ref() -> String { @@ -1826,6 +2044,9 @@ pub struct RepoEntry { /// Branch/tag ref. Defaults to `"refs/heads/main"`. #[serde(default = "default_ref", rename = "ref")] pub repo_ref: String, + /// Service connection name for GitHub/GitHub Enterprise repository resources. + #[serde(default)] + pub endpoint: Option, /// Whether the agent job checks out this repository. Defaults to `true`. #[serde(default = "default_checkout")] pub checkout: bool, @@ -2730,6 +2951,162 @@ impl SanitizeConfigTrait for LabelFilter { mod tests { use super::*; + const IMPORT_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + + fn bare_import(uses: impl Into) -> ImportEntry { + ImportEntry { + uses: uses.into(), + with: serde_json::Map::new(), + endpoint: None, + } + } + + // ─── imports field and spec parsing ───────────────────────────────────── + + #[test] + fn test_import_bare_remote_spec_parses_to_remote() { + let entry = bare_import(format!("owner/repo/path.md@{IMPORT_SHA}")); + + let source = entry.parse_source().unwrap(); + + match source { + ImportSource::Remote(spec) => { + assert_eq!(spec.owner, "owner"); + assert_eq!(spec.repo, "repo"); + assert_eq!(spec.path, "path.md"); + assert_eq!(spec.sha.as_str(), IMPORT_SHA); + assert_eq!(spec.section, None); + assert!(!spec.optional); + } + other => panic!("expected remote import, got {other:?}"), + } + } + + #[test] + fn test_import_object_form_captures_with_and_endpoint() { + let entry: ImportEntry = serde_yaml::from_str(&format!( + r#" +uses: acme/shared/deploy.md@{IMPORT_SHA} +with: + region: us-east-1 + retries: 3 +endpoint: shared-components-connection +"# + )) + .unwrap(); + + assert_eq!( + entry.endpoint.as_deref(), + Some("shared-components-connection") + ); + assert_eq!( + entry.with.get("region").and_then(|v| v.as_str()), + Some("us-east-1") + ); + assert_eq!(entry.with.get("retries").and_then(|v| v.as_i64()), Some(3)); + + match entry.parse_source().unwrap() { + ImportSource::Remote(spec) => { + assert_eq!(spec.owner, "acme"); + assert_eq!(spec.repo, "shared"); + assert_eq!(spec.path, "deploy.md"); + assert_eq!(spec.sha.as_str(), IMPORT_SHA); + } + other => panic!("expected remote import, got {other:?}"), + } + } + + #[test] + fn test_import_section_and_optional_marker_parse() { + let entry = bare_import(format!("owner/repo/path.md@{IMPORT_SHA}#Deploy?")); + + let source = entry.parse_source().unwrap(); + + match source { + ImportSource::Remote(spec) => { + assert_eq!(spec.section.as_deref(), Some("Deploy")); + assert!(spec.optional); + } + other => panic!("expected remote import, got {other:?}"), + } + } + + #[test] + fn test_import_non_sha_remote_refs_are_rejected() { + for ref_part in ["main", "v1.0.0", "abc123"] { + let entry = bare_import(format!("owner/repo/path.md@{ref_part}")); + + let err = entry.parse_source().unwrap_err().to_string(); + + assert!( + err.contains( + "cross-repository imports must be pinned to a full 40-character commit SHA" + ), + "unexpected error for {ref_part}: {err}" + ); + assert!( + err.contains(ref_part), + "error should include rejected ref {ref_part}: {err}" + ); + } + } + + #[test] + fn test_import_local_bare_path_parses_to_local() { + let entry = bare_import("shared/notify.md"); + + let source = entry.parse_source().unwrap(); + + match source { + ImportSource::Local { + path, + section, + optional, + } => { + assert_eq!(path, "shared/notify.md"); + assert_eq!(section, None); + assert!(!optional); + } + other => panic!("expected local import, got {other:?}"), + } + } + + #[test] + fn test_imports_field_deserializes_in_front_matter() { + let yaml = format!( + r#" +name: Test Agent +description: Test imports +imports: + - shared/notify.md + - uses: acme/shared/deploy.md@{IMPORT_SHA} + with: + region: us-east-1 + endpoint: shared-components-connection +"# + ); + + let fm: FrontMatter = serde_yaml::from_str(&yaml).unwrap(); + + assert_eq!(fm.imports.len(), 2); + assert!(matches!( + fm.imports[0].parse_source().unwrap(), + ImportSource::Local { .. } + )); + assert_eq!( + fm.imports[1].endpoint.as_deref(), + Some("shared-components-connection") + ); + assert_eq!( + fm.imports[1].with.get("region").and_then(|v| v.as_str()), + Some("us-east-1") + ); + assert!(matches!( + fm.imports[1].parse_source().unwrap(), + ImportSource::Remote(_) + )); + } + // ─── SupplyChainConfig deserialization + resolution ────────────────────── fn parse_supply_chain(yaml: &str) -> SupplyChainConfig { @@ -3061,9 +3438,13 @@ github-app-token: .unwrap() .clone(); assert_eq!(gat.app_id, "1234567"); - assert_eq!(gat.api_url.as_deref(), Some("https://ghe.example.com/api/v3")); + assert_eq!( + gat.api_url.as_deref(), + Some("https://ghe.example.com/api/v3") + ); assert!(gat.skip_token_revocation); - gat.validate().expect("numeric app-id + https api-url is valid"); + gat.validate() + .expect("numeric app-id + https api-url is valid"); } #[test] @@ -3108,7 +3489,11 @@ github-app-token: let ec = EngineConfig::default(); assert!(ec.github_app_token().is_none()); let opts: EngineOptions = serde_yaml::from_str("id: copilot").unwrap(); - assert!(EngineConfig::Full(Box::new(opts)).github_app_token().is_none()); + assert!( + EngineConfig::Full(Box::new(opts)) + .github_app_token() + .is_none() + ); } #[test] @@ -3120,7 +3505,10 @@ github-app-token: owner: octo-org "#; let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); - let gat = EngineConfig::Full(Box::new(opts)).github_app_token().unwrap().clone(); + let gat = EngineConfig::Full(Box::new(opts)) + .github_app_token() + .unwrap() + .clone(); assert!(gat.repositories.is_empty()); gat.validate().unwrap(); } @@ -3279,8 +3667,7 @@ github-app-token: // accidentally introducing a required field or a non-None serde default. let yaml = "permissions: {}"; let fm: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let pc: PermissionsConfig = - serde_yaml::from_value(fm["permissions"].clone()).unwrap(); + let pc: PermissionsConfig = serde_yaml::from_value(fm["permissions"].clone()).unwrap(); assert!(pc.read.is_none()); assert!(pc.write.is_none()); } @@ -3741,6 +4128,38 @@ Body assert_eq!(reviewed, vec!["add-pr-comment"]); } + #[test] + fn test_custom_tools_included_in_partition_and_names() { + let content = r#"--- +name: "Test" +description: "Test" +safe-outputs: + scripts: + send-notification: + run: node notify.js + jobs: + deploy-thing: + steps: [] + send-notification: + require-approval: true +--- + +Body +"#; + let (fm, _) = super::super::common::parse_markdown(content).unwrap(); + let mut custom = fm.custom_safe_output_tool_names(); + custom.sort(); + assert_eq!(custom, vec!["deploy-thing", "send-notification"]); + // The top-level `send-notification` key is config, not a separate tool. + let mut all = fm.all_safe_output_tool_names(); + all.sort(); + assert_eq!(all, vec!["deploy-thing", "send-notification"]); + // require-approval on the custom tool routes it to reviewed. + let (auto, reviewed) = fm.partition_safe_outputs_by_approval(); + assert_eq!(auto, vec!["deploy-thing"]); + assert_eq!(reviewed, vec!["send-notification"]); + } + #[test] fn test_require_approval_detailed_object() { let content = r#"--- @@ -4243,8 +4662,14 @@ Body assert_eq!(report_flag, Some(false)); // noop config with flat fields let noop = fm.safe_outputs.get("noop").unwrap(); - assert_eq!(noop.get("title-prefix").and_then(|v| v.as_str()), Some("[ado-aw] Agent noop")); - assert_eq!(noop.get("area-path").and_then(|v| v.as_str()), Some("MyProject\\MyTeam")); + assert_eq!( + noop.get("title-prefix").and_then(|v| v.as_str()), + Some("[ado-aw] Agent noop") + ); + assert_eq!( + noop.get("area-path").and_then(|v| v.as_str()), + Some("MyProject\\MyTeam") + ); } #[test] @@ -4297,7 +4722,9 @@ Body let (fm, _) = super::super::common::parse_markdown(implicit).unwrap(); let default_branch = crate::safe_outputs::CreatePrConfig::default().target_branch; assert_eq!( - fm.create_pr_config().unwrap().resolve_target_branch("self", &no_refs), + fm.create_pr_config() + .unwrap() + .resolve_target_branch("self", &no_refs), default_branch ); // Guard the shared default so a future rename can't silently make the diff --git a/src/execute.rs b/src/execute.rs index 11f9dde4..5350d956 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -6,10 +6,12 @@ use anyhow::{Context, Result}; use chrono::{SecondsFormat, Utc}; use log::{debug, error, info, warn}; -use serde::{Serialize, de::DeserializeOwned}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; -use std::collections::HashMap; -use std::path::Path; +use std::collections::{HashMap, HashSet}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use tokio::fs::OpenOptions; use tokio::io::AsyncWriteExt; @@ -23,7 +25,7 @@ use crate::safe_outputs::{ UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, }; -use crate::sanitize::neutralize_pipeline_commands; +use crate::sanitize::{SanitizeConfig, neutralize_pipeline_commands, sanitize, sanitize_config}; // Re-export memory types for use by main.rs pub use crate::tools::cache_memory::{MemoryConfig, process_agent_memory}; @@ -55,6 +57,902 @@ impl ToolFilter { } } +/// Additional `ado-aw execute` custom safe-output modes. +#[derive(Debug, Default, Clone)] +pub struct CustomExecuteOptions { + /// Scripts-style native dispatcher config. + pub custom_config: Option, + /// Jobs-style wrapper phase: `pre` or `post`. + pub custom_phase: Option, + /// Jobs-style custom tool name. + pub tool: Option, + /// Jobs-style pre output path for selected proposals. + pub proposals_out: Option, + /// Jobs-style post input path for component result records. + pub results_in: Option, + /// Compiler-owned component provenance. + pub component_sha: Option, + pub component_source: Option, + pub manifest_digest: Option, + pub schema_digest: Option, +} + +impl CustomExecuteOptions { + /// Whether any custom-mode flag was supplied. + pub fn has_any_custom_flag(&self) -> bool { + self.custom_config.is_some() + || self.custom_phase.is_some() + || self.tool.is_some() + || self.proposals_out.is_some() + || self.results_in.is_some() + || self.component_sha.is_some() + || self.component_source.is_some() + || self.manifest_digest.is_some() + || self.schema_digest.is_some() + } +} + +const CUSTOM_SCHEMA_VERSION: u32 = 1; +const DEFAULT_CUSTOM_MAX: usize = 3; + +fn default_custom_max() -> usize { + DEFAULT_CUSTOM_MAX +} + +fn default_custom_cwd() -> PathBuf { + PathBuf::from(".") +} + +/// Scripts-style custom safe-output config emitted by the compiler. +#[derive(Debug, Deserialize)] +pub struct CustomScriptsConfig { + pub tools: HashMap, +} + +/// One scripts-style custom safe-output handler. +#[derive(Debug, Deserialize)] +pub struct CustomScriptToolConfig { + pub entrypoint: String, + #[serde(default = "default_custom_cwd")] + pub cwd: PathBuf, + #[serde(default = "default_custom_max")] + pub max: usize, +} + +/// Compiler-owned custom component provenance attached to each final record. +#[derive(Debug, Clone, Serialize)] +pub struct CustomComponentProvenance { + pub source: Option, + pub sha: Option, + pub manifest_digest: Option, + pub schema_digest: Option, +} + +/// Attempt metadata attached to each custom execution record. +#[derive(Debug, Clone, Serialize)] +pub struct CustomAttemptMetadata { + pub number: u32, + pub staged: bool, + pub started_at: String, + pub ended_at: String, +} + +/// Final custom safe-output execution record. +/// +/// The top-level `name`, `status`, and `timestamp` fields intentionally mirror +/// the built-in `ExecutionRecord` so the existing audit reader can continue to +/// key off `name`/`status` while custom records carry richer provenance. +#[derive(Debug, Clone, Serialize)] +pub struct CustomExecutionRecord { + pub schema_version: u32, + pub tool: String, + pub proposal_id: String, + pub proposal_index: usize, + pub name: String, + pub status: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + pub component: CustomComponentProvenance, + pub attempt: CustomAttemptMetadata, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub timestamp: String, +} + +#[derive(Debug)] +struct SelectedCustomProposal { + proposal_id: String, + proposal_index: usize, + entry: Value, + budget_result: Option, +} + +impl SelectedCustomProposal { + fn attempted(&self) -> bool { + self.budget_result.is_none() + } +} + +#[derive(Debug, Deserialize)] +struct ScriptResultLine { + status: String, + message: String, + data: Option, +} + +#[derive(Debug, Deserialize)] +struct ComponentResultLine { + #[serde(rename = "schema_version")] + _schema_version: u32, + proposal_id: String, + status: String, + message: String, + data: Option, +} + +struct CustomToolOutcome { + result: ExecutionResult, + record_status: &'static str, +} + +/// Execute a custom safe-output mode. Built-in execution is untouched unless +/// at least one custom flag is present. +pub async fn execute_custom_safe_outputs( + source: &Path, + safe_output_dir: &Path, + dry_run: bool, + options: CustomExecuteOptions, +) -> Result> { + match ( + options.custom_config.as_ref(), + options.custom_phase.as_deref(), + ) { + (Some(config_path), None) => { + execute_custom_scripts(config_path, safe_output_dir, dry_run, &options).await + } + (None, Some("pre")) => { + execute_custom_pre(source, safe_output_dir, dry_run, &options).await?; + Ok(Vec::new()) + } + (None, Some("post")) => { + execute_custom_post(source, safe_output_dir, dry_run, &options).await + } + (Some(_), Some(_)) => { + anyhow::bail!("--custom-config and --custom-phase are mutually exclusive") + } + (None, Some(other)) => { + anyhow::bail!("Unsupported --custom-phase '{other}' (expected 'pre' or 'post')") + } + (None, None) => { + anyhow::bail!("Custom execute mode requested without --custom-config or --custom-phase") + } + } +} + +async fn execute_custom_scripts( + config_path: &Path, + safe_output_dir: &Path, + dry_run: bool, + options: &CustomExecuteOptions, +) -> Result> { + if options.tool.is_some() || options.proposals_out.is_some() || options.results_in.is_some() { + anyhow::bail!( + "--custom-config cannot be combined with --tool, --proposals-out, or --results-in" + ); + } + + let config = load_custom_scripts_config(config_path).await?; + let safe_output_path = safe_output_dir.join(SAFE_OUTPUT_FILENAME); + let Some(entries) = load_safe_output_entries(&safe_output_path).await? else { + return Ok(Vec::new()); + }; + + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); + let provenance = provenance_from_options(options); + let mut budgets: HashMap = config + .tools + .iter() + .map(|(name, tool)| (name.clone(), (0, tool.max))) + .collect(); + + let mut results = Vec::new(); + for (i, entry) in entries.iter().enumerate() { + let Some(tool_name) = entry.get("name").and_then(|name| name.as_str()) else { + continue; + }; + let Some(tool_config) = config.tools.get(tool_name) else { + continue; + }; + + let proposal_context = entry.get("context").and_then(|value| value.as_str()); + let (executed, max) = budgets + .get_mut(tool_name) + .expect("budget map is initialized from config tools"); + let context_id = extract_entry_context(entry); + let proposal_id = proposal_id(tool_name, i); + if let Some(result) = + check_budget(entries.len(), i, tool_name, &context_id, *executed, *max) + { + append_custom_execution_record_for_result( + safe_output_dir, + tool_name, + &proposal_id, + i, + proposal_context, + &result, + provenance.clone(), + 0, + dry_run, + ) + .await; + results.push(result); + continue; + } + *executed += 1; + + let started_at = now_timestamp(); + let outcome = if dry_run { + let message = format!( + "Staged custom tool '{tool_name}' proposal '{proposal_id}'; would run '{}'", + sanitize_config(&tool_config.entrypoint) + ); + custom_status_to_outcome("staged", message, None) + } else { + let cwd = resolve_custom_cwd(config_dir, &tool_config.cwd); + run_custom_entrypoint( + tool_name, + &proposal_id, + &tool_config.entrypoint, + &cwd, + entry, + ) + .await + }; + let ended_at = now_timestamp(); + log_and_print_entry_result(i, entries.len(), tool_name, &outcome.result); + append_custom_execution_record_for_result_with_times( + safe_output_dir, + tool_name, + &proposal_id, + i, + proposal_context, + &outcome.result, + provenance.clone(), + CustomAttemptMetadata { + number: 1, + staged: dry_run, + started_at, + ended_at, + }, + Some(outcome.record_status), + ) + .await; + results.push(outcome.result); + } + + Ok(results) +} + +async fn execute_custom_pre( + source: &Path, + safe_output_dir: &Path, + dry_run: bool, + options: &CustomExecuteOptions, +) -> Result<()> { + let tool = required_custom_tool(options)?; + let proposals_out = options + .proposals_out + .as_ref() + .context("--custom-phase pre requires --proposals-out")?; + if options.custom_config.is_some() || options.results_in.is_some() { + anyhow::bail!("--custom-phase pre cannot be combined with --custom-config or --results-in"); + } + + let max = load_custom_max(source, tool).await?; + let entries = load_entries_or_empty(safe_output_dir).await?; + let selected = select_custom_proposals(&entries, tool, max); + let attempted: Vec = selected + .iter() + .filter(|proposal| proposal.attempted()) + .map(proposal_with_id) + .collect(); + write_ndjson_values(proposals_out, &attempted).await?; + + if dry_run { + // ADO consumes this logging command and exposes the staged contract to + // downstream component steps as `ADO_AW_SAFE_OUTPUTS_STAGED=true`. + println!("##vso[task.setvariable variable=ADO_AW_SAFE_OUTPUTS_STAGED]true"); + } + println!( + "Wrote {} custom proposal(s) for '{}' to {}", + attempted.len(), + sanitize_config(tool), + proposals_out.display() + ); + Ok(()) +} + +async fn execute_custom_post( + source: &Path, + safe_output_dir: &Path, + dry_run: bool, + options: &CustomExecuteOptions, +) -> Result> { + let tool = required_custom_tool(options)?; + let results_in = options + .results_in + .as_ref() + .context("--custom-phase post requires --results-in")?; + if options.custom_config.is_some() || options.proposals_out.is_some() { + anyhow::bail!( + "--custom-phase post cannot be combined with --custom-config or --proposals-out" + ); + } + + let max = load_custom_max(source, tool).await?; + let entries = load_entries_or_empty(safe_output_dir).await?; + let selected = select_custom_proposals(&entries, tool, max); + let attempted_ids: HashSet = selected + .iter() + .filter(|proposal| proposal.attempted()) + .map(|proposal| proposal.proposal_id.clone()) + .collect(); + let mut component_results = read_component_results(results_in, &attempted_ids).await?; + let provenance = provenance_from_options(options); + let mut results = Vec::new(); + + for proposal in selected { + let proposal_context = proposal + .entry + .get("context") + .and_then(|value| value.as_str()); + if let Some(result) = proposal.budget_result { + append_custom_execution_record_for_result( + safe_output_dir, + tool, + &proposal.proposal_id, + proposal.proposal_index, + proposal_context, + &result, + provenance.clone(), + 0, + dry_run, + ) + .await; + results.push(result); + continue; + } + + let started_at = now_timestamp(); + let outcome = if let Some(line) = component_results.remove(&proposal.proposal_id) { + custom_status_to_outcome(&line.status, line.message, line.data) + } else { + CustomToolOutcome { + result: ExecutionResult::failure(format!( + "Missing custom result for proposal_id '{}'", + sanitize(&proposal.proposal_id) + )), + record_status: "failed", + } + }; + let ended_at = now_timestamp(); + append_custom_execution_record_for_result_with_times( + safe_output_dir, + tool, + &proposal.proposal_id, + proposal.proposal_index, + proposal_context, + &outcome.result, + provenance.clone(), + CustomAttemptMetadata { + number: 1, + staged: dry_run || outcome.record_status == "staged", + started_at, + ended_at, + }, + Some(outcome.record_status), + ) + .await; + results.push(outcome.result); + } + + Ok(results) +} + +fn required_custom_tool(options: &CustomExecuteOptions) -> Result<&str> { + options + .tool + .as_deref() + .context("--custom-phase requires --tool") +} + +async fn load_custom_scripts_config(path: &Path) -> Result { + let contents = tokio::fs::read_to_string(path) + .await + .with_context(|| format!("Failed to read custom config: {}", path.display()))?; + serde_json::from_str(&contents) + .with_context(|| format!("Failed to parse custom config: {}", path.display())) +} + +async fn load_entries_or_empty(safe_output_dir: &Path) -> Result> { + let safe_output_path = safe_output_dir.join(SAFE_OUTPUT_FILENAME); + Ok(load_safe_output_entries(&safe_output_path) + .await? + .unwrap_or_default()) +} + +async fn load_custom_max(source: &Path, tool: &str) -> Result { + let content = tokio::fs::read_to_string(source) + .await + .with_context(|| format!("Failed to read source file: {}", source.display()))?; + let parsed = crate::compile::parse_markdown_detailed(&content) + .with_context(|| format!("Failed to parse source file: {}", source.display()))?; + let mut front_matter = parsed.front_matter; + front_matter.sanitize_config_fields(); + Ok(custom_max_from_safe_outputs( + &front_matter.safe_outputs, + tool, + )) +} + +fn custom_max_from_safe_outputs(safe_outputs: &HashMap, tool: &str) -> usize { + max_from_value(safe_outputs.get(tool)) + .or_else(|| nested_custom_max(safe_outputs, "scripts", tool)) + .or_else(|| nested_custom_max(safe_outputs, "jobs", tool)) + .unwrap_or(DEFAULT_CUSTOM_MAX) +} + +fn nested_custom_max( + safe_outputs: &HashMap, + section: &str, + tool: &str, +) -> Option { + safe_outputs + .get(section) + .and_then(|section| section.get(tool)) + .and_then(|tool_cfg| max_from_value(Some(tool_cfg))) +} + +fn max_from_value(value: Option<&Value>) -> Option { + value + .and_then(|v| v.get("max")) + .and_then(|v| v.as_u64()) + .map(|v| v as usize) +} + +fn select_custom_proposals( + entries: &[Value], + tool: &str, + max: usize, +) -> Vec { + let mut selected = Vec::new(); + let mut executed = 0usize; + for (i, entry) in entries.iter().enumerate() { + if entry.get("name").and_then(|name| name.as_str()) != Some(tool) { + continue; + } + let context_id = extract_entry_context(entry); + let budget_result = check_budget(entries.len(), i, tool, &context_id, executed, max); + if budget_result.is_none() { + executed += 1; + } + selected.push(SelectedCustomProposal { + proposal_id: proposal_id(tool, i), + proposal_index: i, + entry: entry.clone(), + budget_result, + }); + } + selected +} + +fn proposal_id(tool: &str, index: usize) -> String { + format!("{}-{}", sanitize_config(tool), index) +} + +fn proposal_with_id(proposal: &SelectedCustomProposal) -> Value { + let mut value = proposal.entry.clone(); + if let Value::Object(ref mut map) = value { + map.insert( + "proposal_id".to_string(), + Value::String(proposal.proposal_id.clone()), + ); + map.insert( + "proposal_index".to_string(), + Value::Number(serde_json::Number::from(proposal.proposal_index)), + ); + } + value +} + +async fn write_ndjson_values(path: &Path, values: &[Value]) -> Result<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + let mut contents = String::new(); + for value in values { + contents.push_str(&serde_json::to_string(value).context("Failed to serialize proposal")?); + contents.push('\n'); + } + tokio::fs::write(path, contents) + .await + .with_context(|| format!("Failed to write proposals file: {}", path.display())) +} + +fn resolve_custom_cwd(config_dir: &Path, cwd: &Path) -> PathBuf { + if cwd.is_absolute() { + cwd.to_path_buf() + } else { + config_dir.join(cwd) + } +} + +async fn run_custom_entrypoint( + tool: &str, + proposal_id: &str, + entrypoint: &str, + cwd: &Path, + proposal: &Value, +) -> CustomToolOutcome { + let proposal_json = match serde_json::to_string(proposal) { + Ok(json) => json, + Err(err) => { + return CustomToolOutcome { + result: ExecutionResult::failure(format!( + "Failed to serialize custom proposal '{}': {}", + sanitize(proposal_id), + sanitize(&err.to_string()) + )), + record_status: "failed", + }; + } + }; + + let mut command = if cfg!(windows) { + let mut command = Command::new("cmd"); + command.arg("/C").arg(entrypoint); + command + } else { + let mut command = Command::new("sh"); + command.arg("-c").arg(entrypoint); + command + }; + command + .current_dir(cwd) + .env("AW_PROPOSAL", &proposal_json) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = match command.spawn() { + Ok(child) => child, + Err(err) => { + return CustomToolOutcome { + result: ExecutionResult::failure(format!( + "Failed to start custom tool '{}': {}", + sanitize_config(tool), + sanitize(&err.to_string()) + )), + record_status: "failed", + }; + } + }; + + if let Some(stdin) = child.stdin.as_mut() + && let Err(err) = stdin.write_all(proposal_json.as_bytes()) + { + return CustomToolOutcome { + result: ExecutionResult::failure(format!( + "Failed to write proposal '{}' to custom tool stdin: {}", + sanitize(proposal_id), + sanitize(&err.to_string()) + )), + record_status: "failed", + }; + } + + let output = match child.wait_with_output() { + Ok(output) => output, + Err(err) => { + return CustomToolOutcome { + result: ExecutionResult::failure(format!( + "Failed to wait for custom tool '{}': {}", + sanitize_config(tool), + sanitize(&err.to_string()) + )), + record_status: "failed", + }; + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + return CustomToolOutcome { + result: ExecutionResult::failure(format!( + "Custom tool '{}' exited with status {}{}", + sanitize_config(tool), + output.status, + if detail.is_empty() { + String::new() + } else { + format!(": {}", sanitize(detail)) + } + )), + record_status: "failed", + }; + } + + parse_script_result_stdout(tool, &output.stdout) +} + +fn parse_script_result_stdout(tool: &str, stdout: &[u8]) -> CustomToolOutcome { + let stdout = String::from_utf8_lossy(stdout); + let lines: Vec<&str> = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect(); + if lines.len() != 1 { + return CustomToolOutcome { + result: ExecutionResult::failure(format!( + "Custom tool '{}' must print exactly one JSON line, got {}", + sanitize_config(tool), + lines.len() + )), + record_status: "failed", + }; + } + let parsed: ScriptResultLine = match serde_json::from_str(lines[0]) { + Ok(parsed) => parsed, + Err(err) => { + return CustomToolOutcome { + result: ExecutionResult::failure(format!( + "Custom tool '{}' printed malformed result JSON: {}", + sanitize_config(tool), + sanitize(&err.to_string()) + )), + record_status: "failed", + }; + } + }; + custom_status_to_outcome(&parsed.status, parsed.message, parsed.data) +} + +async fn read_component_results( + path: &Path, + attempted_ids: &HashSet, +) -> Result> { + let values = if path.exists() { + ndjson::read_ndjson_file(path).await? + } else { + Vec::new() + }; + let mut results = HashMap::new(); + for value in values { + let schema_version = value.get("schema_version").and_then(|v| v.as_u64()); + if schema_version != Some(CUSTOM_SCHEMA_VERSION as u64) { + anyhow::bail!( + "Custom result record has missing or unsupported schema_version: {}", + value + .get("schema_version") + .map(Value::to_string) + .unwrap_or_else(|| "".to_string()) + ); + } + let line: ComponentResultLine = + serde_json::from_value(value).context("Malformed custom result record")?; + if !attempted_ids.contains(&line.proposal_id) { + anyhow::bail!( + "Custom result references unknown proposal_id '{}'", + sanitize(&line.proposal_id) + ); + } + if results.insert(line.proposal_id.clone(), line).is_some() { + anyhow::bail!("Duplicate custom result record for proposal_id"); + } + } + Ok(results) +} + +fn custom_status_to_outcome( + status: &str, + message: String, + data: Option, +) -> CustomToolOutcome { + let message = sanitize(&message); + let data = data.map(sanitize_json_value); + match status { + "success" | "succeeded" => CustomToolOutcome { + result: match data { + Some(data) => ExecutionResult::success_with_data(message, data), + None => ExecutionResult::success(message), + }, + record_status: "succeeded", + }, + "failure" | "failed" => CustomToolOutcome { + result: match data { + Some(data) => ExecutionResult::failure_with_data(message, data), + None => ExecutionResult::failure(message), + }, + record_status: "failed", + }, + "staged" => CustomToolOutcome { + result: match data { + Some(data) => ExecutionResult::success_with_data(message, data), + None => ExecutionResult::success(message), + }, + record_status: "staged", + }, + other => CustomToolOutcome { + result: ExecutionResult::failure(format!( + "Custom result has unsupported status '{}'", + sanitize(other) + )), + record_status: "failed", + }, + } +} + +fn sanitize_json_value(value: Value) -> Value { + match value { + Value::String(s) => Value::String(sanitize(&s)), + Value::Array(values) => Value::Array(values.into_iter().map(sanitize_json_value).collect()), + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(key, value)| (sanitize(&key), sanitize_json_value(value))) + .collect(), + ), + other => other, + } +} + +fn provenance_from_options(options: &CustomExecuteOptions) -> CustomComponentProvenance { + CustomComponentProvenance { + source: options.component_source.as_deref().map(sanitize_config), + sha: options.component_sha.as_deref().map(sanitize_config), + manifest_digest: options.manifest_digest.as_deref().map(sanitize_config), + schema_digest: options.schema_digest.as_deref().map(sanitize_config), + } +} + +fn now_timestamp() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn custom_record_status(result: &ExecutionResult) -> &'static str { + if result.is_budget_exhausted() { + "budget_exhausted" + } else if result.message.starts_with("Staged custom tool") { + "staged" + } else { + execution_record_status(result) + } +} + +#[allow(clippy::too_many_arguments)] +async fn append_custom_execution_record_for_result( + safe_output_dir: &Path, + tool: &str, + proposal_id: &str, + proposal_index: usize, + proposal_context: Option<&str>, + result: &ExecutionResult, + provenance: CustomComponentProvenance, + attempt_number: u32, + staged: bool, +) { + let timestamp = now_timestamp(); + append_custom_execution_record_for_result_with_times( + safe_output_dir, + tool, + proposal_id, + proposal_index, + proposal_context, + result, + provenance, + CustomAttemptMetadata { + number: attempt_number, + staged, + started_at: timestamp.clone(), + ended_at: timestamp, + }, + None, + ) + .await; +} + +#[allow(clippy::too_many_arguments)] +async fn append_custom_execution_record_for_result_with_times( + safe_output_dir: &Path, + tool: &str, + proposal_id: &str, + proposal_index: usize, + proposal_context: Option<&str>, + result: &ExecutionResult, + provenance: CustomComponentProvenance, + attempt: CustomAttemptMetadata, + record_status_override: Option<&str>, +) { + let status = record_status_override + .unwrap_or_else(|| custom_record_status(result)) + .to_string(); + let data = result.data.clone().map(sanitize_json_value); + let message = sanitize(&result.message); + let record = CustomExecutionRecord { + schema_version: CUSTOM_SCHEMA_VERSION, + tool: sanitize_config(tool), + proposal_id: sanitize(proposal_id), + proposal_index, + name: sanitize_config(tool), + status: status.clone(), + message: message.clone(), + data: data.clone(), + component: provenance, + attempt, + context: proposal_context.map(sanitize), + result: if matches!(status.as_str(), "succeeded" | "staged") { + data + } else { + None + }, + error: if matches!(status.as_str(), "succeeded" | "staged") { + None + } else { + Some(message) + }, + timestamp: now_timestamp(), + }; + append_custom_execution_record(safe_output_dir, &record).await; +} + +async fn append_custom_execution_record(safe_output_dir: &Path, record: &CustomExecutionRecord) { + if let Err(err) = append_custom_execution_record_impl(safe_output_dir, record).await { + warn!( + "Failed to append custom execution record for {}: {}", + record.tool, + neutralize_pipeline_commands(&err.to_string()) + ); + } +} + +async fn append_custom_execution_record_impl( + safe_output_dir: &Path, + record: &CustomExecutionRecord, +) -> Result<()> { + let line = serde_json::to_string(record) + .context("Failed to serialize custom execution record")? + + "\n"; + let path = safe_output_dir.join(EXECUTED_NDJSON_FILENAME); + let mut file = OpenOptions::new() + .append(true) + .create(true) + .open(&path) + .await + .with_context(|| format!("Failed to open executed NDJSON file: {}", path.display()))?; + file.write_all(line.as_bytes()) + .await + .with_context(|| format!("Failed to append executed NDJSON file: {}", path.display()))?; + file.flush() + .await + .with_context(|| format!("Failed to flush executed NDJSON file: {}", path.display()))?; + Ok(()) +} + /// Execute all safe outputs from the NDJSON file in the specified directory pub async fn execute_safe_outputs( safe_output_dir: &Path, @@ -113,9 +1011,16 @@ pub async fn execute_safe_outputs( let mut results = Vec::new(); for (i, entry) in entries.iter().enumerate() { - if let Some(result) = - process_one_entry(i, entries.len(), entry, &mut budgets, filter, ctx, safe_output_dir) - .await + if let Some(result) = process_one_entry( + i, + entries.len(), + entry, + &mut budgets, + filter, + ctx, + safe_output_dir, + ) + .await { results.push(result); } @@ -198,8 +1103,13 @@ async fn process_one_entry( // Budget is consumed before execution so that failed attempts (target policy rejection, // network errors) still count — this prevents unbounded retries against a failing endpoint. if let Some(result) = enforce_budget(entry, budgets, total, i) { - append_execution_record(safe_output_dir, proposal_tool_name, &result, proposal_context) - .await; + append_execution_record( + safe_output_dir, + proposal_tool_name, + &result, + proposal_context, + ) + .await; return Some(result); } @@ -748,6 +1658,379 @@ mod tests { assert!(f.allows("add-pr-comment")); } + async fn write_success_script(dir: &Path) -> String { + tokio::fs::write( + dir.join("success.py"), + "import json\nprint(json.dumps({'status':'success','message':'ok'}))\n", + ) + .await + .unwrap(); + "python success.py".to_string() + } + + fn failing_entrypoint() -> &'static str { + if cfg!(windows) { "exit /B 1" } else { "exit 1" } + } + + async fn write_safe_outputs(dir: &Path, contents: &str) { + tokio::fs::write(dir.join(SAFE_OUTPUT_FILENAME), contents) + .await + .unwrap(); + } + + async fn write_custom_config(dir: &Path, entrypoint: &str, max: usize) -> PathBuf { + let path = dir.join("custom-config.json"); + let config = serde_json::json!({ + "tools": { + "send-notification": { + "entrypoint": entrypoint, + "cwd": ".", + "max": max + } + } + }); + tokio::fs::write(&path, serde_json::to_string(&config).unwrap()) + .await + .unwrap(); + path + } + + async fn write_custom_source(dir: &Path, max: usize) -> PathBuf { + let path = dir.join("agent.md"); + let content = format!( + r#"--- +name: Custom executor test +description: Test custom executor +safe-outputs: + jobs: + send-notification: + max: {max} +--- + +Test body. +"# + ); + tokio::fs::write(&path, content).await.unwrap(); + path + } + + async fn read_executed_records(dir: &Path) -> Vec { + ndjson::read_ndjson_file(&dir.join(EXECUTED_NDJSON_FILENAME)) + .await + .unwrap() + } + + #[tokio::test] + async fn test_custom_execute_scripts_native_dispatch_success_record() { + let temp_dir = tempfile::tempdir().unwrap(); + write_safe_outputs( + temp_dir.path(), + r#"{"name":"send-notification","context":"hello"}"#, + ) + .await; + let entrypoint = write_success_script(temp_dir.path()).await; + let config_path = write_custom_config(temp_dir.path(), &entrypoint, 3).await; + + let results = execute_custom_safe_outputs( + Path::new("unused.md"), + temp_dir.path(), + false, + CustomExecuteOptions { + custom_config: Some(config_path), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert!(results[0].success); + let records = read_executed_records(temp_dir.path()).await; + assert_eq!(records[0]["name"], "send-notification"); + assert_eq!(records[0]["status"], "succeeded"); + assert_eq!(records[0]["message"], "ok"); + } + + #[tokio::test] + async fn test_custom_execute_scripts_native_dispatch_failure_record() { + let temp_dir = tempfile::tempdir().unwrap(); + write_safe_outputs(temp_dir.path(), r#"{"name":"send-notification"}"#).await; + let config_path = write_custom_config(temp_dir.path(), failing_entrypoint(), 3).await; + + let results = execute_custom_safe_outputs( + Path::new("unused.md"), + temp_dir.path(), + false, + CustomExecuteOptions { + custom_config: Some(config_path), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert!(!results[0].success); + let records = read_executed_records(temp_dir.path()).await; + assert_eq!(records[0]["status"], "failed"); + } + + #[tokio::test] + async fn test_custom_execute_scripts_budget_exhausted_record() { + let temp_dir = tempfile::tempdir().unwrap(); + write_safe_outputs( + temp_dir.path(), + r#"{"name":"send-notification","context":"first"} +{"name":"send-notification","context":"second"} +"#, + ) + .await; + let entrypoint = write_success_script(temp_dir.path()).await; + let config_path = write_custom_config(temp_dir.path(), &entrypoint, 1).await; + + let results = execute_custom_safe_outputs( + Path::new("unused.md"), + temp_dir.path(), + false, + CustomExecuteOptions { + custom_config: Some(config_path), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(results.len(), 2); + assert!(results[1].is_budget_exhausted()); + let records = read_executed_records(temp_dir.path()).await; + assert_eq!(records[1]["status"], "budget_exhausted"); + } + + #[tokio::test] + async fn test_custom_execute_scripts_dry_run_stages_without_spawn() { + let temp_dir = tempfile::tempdir().unwrap(); + write_safe_outputs(temp_dir.path(), r#"{"name":"send-notification"}"#).await; + let config_path = + write_custom_config(temp_dir.path(), "definitely-not-a-real-command-ado-aw", 3).await; + + let results = execute_custom_safe_outputs( + Path::new("unused.md"), + temp_dir.path(), + true, + CustomExecuteOptions { + custom_config: Some(config_path), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert!(results[0].success); + let records = read_executed_records(temp_dir.path()).await; + assert_eq!(records[0]["status"], "staged"); + } + + #[tokio::test] + async fn test_custom_execute_jobs_pre_writes_filtered_proposals_with_ids() { + let temp_dir = tempfile::tempdir().unwrap(); + let source = write_custom_source(temp_dir.path(), 2).await; + let proposals_out = temp_dir.path().join("proposals.ndjson"); + write_safe_outputs( + temp_dir.path(), + r#"{"name":"send-notification","message":"first"} +{"name":"noop","context":"ignore"} +{"name":"send-notification","message":"second"} +"#, + ) + .await; + + execute_custom_safe_outputs( + &source, + temp_dir.path(), + false, + CustomExecuteOptions { + custom_phase: Some("pre".to_string()), + tool: Some("send-notification".to_string()), + proposals_out: Some(proposals_out.clone()), + ..Default::default() + }, + ) + .await + .unwrap(); + + let proposals = ndjson::read_ndjson_file(&proposals_out).await.unwrap(); + assert_eq!(proposals.len(), 2); + assert_eq!(proposals[0]["proposal_id"], "send-notification-0"); + assert_eq!(proposals[1]["proposal_id"], "send-notification-2"); + } + + #[tokio::test] + async fn test_custom_execute_jobs_post_enriches_component_results() { + let temp_dir = tempfile::tempdir().unwrap(); + let source = write_custom_source(temp_dir.path(), 2).await; + let results_in = temp_dir.path().join("results.ndjson"); + write_safe_outputs( + temp_dir.path(), + r#"{"name":"send-notification","context":"first"} +{"name":"send-notification","context":"second"} +"#, + ) + .await; + tokio::fs::write( + &results_in, + r#"{"schema_version":1,"proposal_id":"send-notification-0","status":"success","message":"ok0","data":{"url":"https://example.com"}} +{"schema_version":1,"proposal_id":"send-notification-1","status":"success","message":"ok1"} +"#, + ) + .await + .unwrap(); + + let results = execute_custom_safe_outputs( + &source, + temp_dir.path(), + false, + CustomExecuteOptions { + custom_phase: Some("post".to_string()), + tool: Some("send-notification".to_string()), + results_in: Some(results_in), + component_source: Some("repo/path".to_string()), + component_sha: Some("abc123".to_string()), + manifest_digest: Some("sha256:manifest".to_string()), + schema_digest: Some("sha256:schema".to_string()), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(results.len(), 2); + assert!(results.iter().all(|result| result.success)); + let records = read_executed_records(temp_dir.path()).await; + assert_eq!(records[0]["component"]["source"], "repo/path"); + assert_eq!(records[0]["component"]["sha"], "abc123"); + assert_eq!(records[0]["status"], "succeeded"); + } + + #[tokio::test] + async fn test_custom_execute_jobs_post_missing_result_becomes_failure() { + let temp_dir = tempfile::tempdir().unwrap(); + let source = write_custom_source(temp_dir.path(), 2).await; + let results_in = temp_dir.path().join("results.ndjson"); + write_safe_outputs( + temp_dir.path(), + r#"{"name":"send-notification"} +{"name":"send-notification"} +"#, + ) + .await; + tokio::fs::write( + &results_in, + r#"{"schema_version":1,"proposal_id":"send-notification-0","status":"success","message":"ok"}"#, + ) + .await + .unwrap(); + + let results = execute_custom_safe_outputs( + &source, + temp_dir.path(), + false, + CustomExecuteOptions { + custom_phase: Some("post".to_string()), + tool: Some("send-notification".to_string()), + results_in: Some(results_in), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(results.len(), 2); + assert!(!results[1].success); + assert!(results[1].message.contains("Missing custom result")); + let records = read_executed_records(temp_dir.path()).await; + assert_eq!(records[1]["status"], "failed"); + } + + #[tokio::test] + async fn test_custom_execute_jobs_post_unknown_schema_version_fails_closed() { + let temp_dir = tempfile::tempdir().unwrap(); + let source = write_custom_source(temp_dir.path(), 1).await; + let results_in = temp_dir.path().join("results.ndjson"); + write_safe_outputs(temp_dir.path(), r#"{"name":"send-notification"}"#).await; + tokio::fs::write( + &results_in, + r#"{"schema_version":99,"proposal_id":"send-notification-0","status":"success","message":"ok"}"#, + ) + .await + .unwrap(); + + let result = execute_custom_safe_outputs( + &source, + temp_dir.path(), + false, + CustomExecuteOptions { + custom_phase: Some("post".to_string()), + tool: Some("send-notification".to_string()), + results_in: Some(results_in), + ..Default::default() + }, + ) + .await; + + assert!(result.is_err()); + } + + #[test] + fn test_custom_execution_record_serializes_name_and_status() { + let record = CustomExecutionRecord { + schema_version: 1, + tool: "send-notification".to_string(), + proposal_id: "send-notification-0".to_string(), + proposal_index: 0, + name: "send-notification".to_string(), + status: "succeeded".to_string(), + message: "ok".to_string(), + data: None, + component: CustomComponentProvenance { + source: None, + sha: None, + manifest_digest: None, + schema_digest: None, + }, + attempt: CustomAttemptMetadata { + number: 1, + staged: false, + started_at: "2026-01-01T00:00:00Z".to_string(), + ended_at: "2026-01-01T00:00:01Z".to_string(), + }, + context: None, + result: None, + error: None, + timestamp: "2026-01-01T00:00:01Z".to_string(), + }; + + let value = serde_json::to_value(record).unwrap(); + assert_eq!(value["name"], "send-notification"); + assert_eq!(value["status"], "succeeded"); + } + + #[tokio::test] + async fn test_execute_no_custom_flags_normal_path_unaffected_smoke() { + let temp_dir = tempfile::tempdir().unwrap(); + assert!(!CustomExecuteOptions::default().has_any_custom_flag()); + + let results = execute_safe_outputs( + temp_dir.path(), + &ExecutionContext::default(), + &ToolFilter::default(), + ) + .await + .unwrap(); + + assert!(results.is_empty()); + } + #[test] fn test_stdout_print_neutralizes_result_message_pipeline_commands() { let message = "Uploaded '##vso[task.setvariable variable=X]y.txt'"; @@ -877,7 +2160,9 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let ctx = ExecutionContext::default(); - let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()).await.unwrap(); + let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); assert!(results.is_empty()); } @@ -893,7 +2178,9 @@ mod tests { tokio::fs::write(&safe_output_path, ndjson).await.unwrap(); let ctx = ExecutionContext::default(); - let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()).await.unwrap(); + let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); assert_eq!(results.len(), 2); assert!(results[0].success); @@ -913,7 +2200,9 @@ mod tests { tokio::fs::write(&safe_output_path, "").await.unwrap(); let ctx = ExecutionContext::default(); - let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()).await.unwrap(); + let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); assert!(results.is_empty()); } @@ -936,7 +2225,9 @@ mod tests { dry_run: true, ..Default::default() }; - let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()).await.unwrap(); + let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); assert_eq!(results.len(), 2); let executed_path = temp_dir.path().join(EXECUTED_NDJSON_FILENAME); @@ -967,7 +2258,9 @@ mod tests { dry_run: true, ..Default::default() }; - let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()).await.unwrap(); + let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); assert_eq!(results.len(), 2); let manifest = read_executed_manifest(&temp_dir).await; @@ -988,7 +2281,9 @@ mod tests { tokio::fs::write(&safe_output_path, "").await.unwrap(); let ctx = ExecutionContext::default(); - let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()).await.unwrap(); + let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); assert!(results.is_empty()); assert!(!temp_dir.path().join(EXECUTED_NDJSON_FILENAME).exists()); } @@ -1492,7 +2787,9 @@ mod tests { tokio::fs::write(&safe_output_path, ndjson).await.unwrap(); let ctx = ExecutionContext::default(); - let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()).await.unwrap(); + let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); // One entry processed (as a failure — unknown tool) assert_eq!(results.len(), 1); @@ -1659,7 +2956,9 @@ mod tests { ..Default::default() }; - let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()).await.unwrap(); + let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); assert_eq!(results.len(), 5); // Second create-work-item should be skipped @@ -1695,7 +2994,9 @@ mod tests { ..Default::default() }; - let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()).await.unwrap(); + let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); assert_eq!(results.len(), 1); assert!(results[0].success, "dry-run should succeed"); assert!( @@ -1727,7 +3028,9 @@ mod tests { ..Default::default() }; - let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()).await.unwrap(); + let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); assert_eq!(results.len(), 2); // create-work-item goes through Executor trait → dry-run intercepted assert!(results[0].message.contains("[DRY-RUN]")); diff --git a/src/main.rs b/src/main.rs index a6091677..966f63d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod list; mod logging; mod mcp; mod mcp_author; +mod mcp_custom_tools; mod ndjson; mod remove; mod run; @@ -200,6 +201,7 @@ enum GraphCmd { } #[derive(Subcommand, Debug)] +#[allow(clippy::large_enum_variant)] enum Commands { /// Compile markdown to pipeline definition (or recompile all detected pipelines) Compile { @@ -242,6 +244,10 @@ enum Commands { /// Only expose these safe output tools (can be repeated). If omitted, all tools are exposed. #[arg(long = "enabled-tools")] enabled_tools: Vec, + /// Path to a compiler-generated JSON file of custom safe-output tool + /// definitions to register as dynamic MCP tools. + #[arg(long = "custom-tools")] + custom_tools: Option, }, /// Run the author-facing MCP server over stdio (IDE/Copilot Chat integration) McpAuthor {}, @@ -274,6 +280,33 @@ enum Commands { /// tools wait for approval. #[arg(long = "exclude")] exclude: Vec, + /// Compiler-generated scripts-style custom safe-output dispatcher config. + #[arg(long = "custom-config")] + custom_config: Option, + /// Jobs-style custom safe-output wrapper phase: pre or post. + #[arg(long = "custom-phase")] + custom_phase: Option, + /// Custom safe-output tool name for jobs-style pre/post phases. + #[arg(long = "tool")] + tool: Option, + /// Jobs-style pre phase output path for filtered proposals. + #[arg(long = "proposals-out")] + proposals_out: Option, + /// Jobs-style post phase input path for component result records. + #[arg(long = "results-in")] + results_in: Option, + /// Compiler-owned custom component source provenance. + #[arg(long = "component-source")] + component_source: Option, + /// Compiler-owned custom component SHA provenance. + #[arg(long = "component-sha")] + component_sha: Option, + /// Compiler-owned custom component manifest digest provenance. + #[arg(long = "manifest-digest")] + manifest_digest: Option, + /// Compiler-owned custom component schema digest provenance. + #[arg(long = "schema-digest")] + schema_digest: Option, }, /// Run SafeOutputs MCP server over HTTP (for MCPG integration) McpHttp { @@ -290,6 +323,10 @@ enum Commands { /// Only expose these safe output tools (can be repeated). If omitted, all tools are exposed. #[arg(long = "enabled-tools")] enabled_tools: Vec, + /// Path to a compiler-generated JSON file of custom safe-output tool + /// definitions to register as dynamic MCP tools. + #[arg(long = "custom-tools")] + custom_tools: Option, }, /// Initialize a repository for AI-first agentic workflow authoring Init { @@ -836,9 +873,7 @@ async fn build_execution_context( ctx.tool_configs = front_matter .safe_outputs .iter() - .filter(|(k, _)| { - !crate::compile::types::SAFE_OUTPUT_RESERVED_KEYS.contains(&k.as_str()) - }) + .filter(|(k, _)| !crate::compile::types::SAFE_OUTPUT_RESERVED_KEYS.contains(&k.as_str())) .map(|(k, v)| (k.clone(), v.clone())) .collect(); // Merge ado-aw-debug.create-issue config under the same tool_configs map @@ -1077,13 +1112,20 @@ async fn main() -> Result<()> { output_directory, bounding_directory, enabled_tools, + custom_tools, } => { let filter = if enabled_tools.is_empty() { None } else { Some(enabled_tools) }; - mcp::run(&output_directory, &bounding_directory, filter.as_deref()).await?; + mcp::run( + &output_directory, + &bounding_directory, + filter.as_deref(), + custom_tools.as_deref(), + ) + .await?; } Commands::McpAuthor {} => { mcp_author::run_stdio().await?; @@ -1097,17 +1139,65 @@ async fn main() -> Result<()> { dry_run, only, exclude, + custom_config, + custom_phase, + tool, + proposals_out, + results_in, + component_source, + component_sha, + manifest_digest, + schema_digest, } => { - run_execute( - source, - safe_output_dir, - output_dir, - ado_org_url, - ado_project, - dry_run, - execute::ToolFilter { only, exclude }, - ) - .await?; + let custom_options = execute::CustomExecuteOptions { + custom_config, + custom_phase, + tool, + proposals_out, + results_in, + component_source, + component_sha, + manifest_digest, + schema_digest, + }; + if custom_options.has_any_custom_flag() { + if output_dir.is_some() + || ado_org_url.is_some() + || ado_project.is_some() + || !only.is_empty() + || !exclude.is_empty() + { + anyhow::bail!( + "Custom execute modes cannot be combined with --output-dir, --ado-org-url, --ado-project, --only, or --exclude" + ); + } + let results = execute::execute_custom_safe_outputs( + &source, + &safe_output_dir, + dry_run, + custom_options, + ) + .await?; + print_execution_summary(&results); + let failure_count = results.iter().filter(|r| !r.success).count(); + let warning_count = results.iter().filter(|r| r.is_warning()).count(); + if failure_count > 0 { + std::process::exit(1); + } else if warning_count > 0 { + std::process::exit(2); + } + } else { + run_execute( + source, + safe_output_dir, + output_dir, + ado_org_url, + ado_project, + dry_run, + execute::ToolFilter { only, exclude }, + ) + .await?; + } } Commands::McpHttp { port, @@ -1115,6 +1205,7 @@ async fn main() -> Result<()> { output_directory, bounding_directory, enabled_tools, + custom_tools, } => { let filter = if enabled_tools.is_empty() { None @@ -1127,6 +1218,7 @@ async fn main() -> Result<()> { port, api_key.as_deref(), filter.as_deref(), + custom_tools.as_deref(), ) .await?; } diff --git a/src/mcp.rs b/src/mcp.rs index 63e66077..13228f6c 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -239,9 +239,7 @@ fn resolve_git_dir_for_patch( } /// Check whether the working tree has uncommitted changes (staged or unstaged). -async fn check_uncommitted_changes( - git_dir: &std::path::Path, -) -> Result { +async fn check_uncommitted_changes(git_dir: &std::path::Path) -> Result { use tokio::process::Command; let status_output = Command::new("git") .args(["status", "--porcelain"]) @@ -395,10 +393,7 @@ fn should_keep_tool(tool_name: &str, enabled_tools: Option<&[String]>) -> bool { /// Apply the `enabled_tools` filter to `tool_router`, warn about unknown names, /// and log the before/after counts. -fn apply_tool_filter( - tool_router: &mut ToolRouter, - enabled_tools: Option<&[String]>, -) { +fn apply_tool_filter(tool_router: &mut ToolRouter, enabled_tools: Option<&[String]>) { let all_tools: Vec = tool_router .list_all() .iter() @@ -452,6 +447,12 @@ impl SafeOutputs { self.output_directory.join(SAFE_OUTPUT_FILENAME) } + /// Full path to the safe output NDJSON file, for dynamic custom-tool + /// handlers registered outside the static tool router. + pub(crate) fn custom_output_path(&self) -> PathBuf { + self.safe_output_path() + } + /// Read the current contents of the safe output file as NDJSON async fn read_safe_output_file(&self) -> Result> { ndjson::read_ndjson_file(&self.safe_output_path()).await @@ -488,6 +489,7 @@ impl SafeOutputs { bounding_directory: impl Into, output_directory: impl Into, enabled_tools: Option<&[String]>, + custom_tools: Option<&std::path::Path>, ) -> Result { let bounding_dir = bounding_directory.into(); let output_dir = output_directory.into(); @@ -522,6 +524,13 @@ impl SafeOutputs { // `None`; otherwise filtered against the explicit allowlist. apply_tool_filter(&mut tool_router, enabled_tools); + // Register config-driven custom safe-output tools (if any). These are + // added AFTER the built-in filter so a custom tool can never shadow a + // built-in (collisions are skipped with a warning). + if let Some(path) = custom_tools { + crate::mcp_custom_tools::apply_custom_tools(&mut tool_router, path)?; + } + Ok(Self { bounding_directory: bounding_dir, output_directory: output_dir, @@ -1513,15 +1522,21 @@ pub async fn run( output_directory: &str, bounding_directory: &str, enabled_tools: Option<&[String]>, + custom_tools: Option<&std::path::Path>, ) -> Result<()> { // Create and run the server with STDIO transport - let service = SafeOutputs::new(bounding_directory, output_directory, enabled_tools) - .await? - .serve(stdio()) - .await - .inspect_err(|e| { - error!("Error starting MCP server: {}", e); - })?; + let service = SafeOutputs::new( + bounding_directory, + output_directory, + enabled_tools, + custom_tools, + ) + .await? + .serve(stdio()) + .await + .inspect_err(|e| { + error!("Error starting MCP server: {}", e); + })?; service .waiting() .await @@ -1539,6 +1554,7 @@ pub async fn run_http( port: u16, api_key: Option<&str>, enabled_tools: Option<&[String]>, + custom_tools: Option<&std::path::Path>, ) -> Result<()> { use axum::Router; use rmcp::transport::streamable_http_server::{ @@ -1579,7 +1595,8 @@ pub async fn run_http( // The factory closure runs on a Tokio worker thread, so we cannot // use block_on() inside it — that would panic with "Cannot start // a runtime from within a runtime". - let safe_outputs_template = SafeOutputs::new(&bounding, &output, enabled_tools).await?; + let safe_outputs_template = + SafeOutputs::new(&bounding, &output, enabled_tools, custom_tools).await?; let mcp_service = StreamableHttpService::new( move || Ok(safe_outputs_template.clone()), session_manager, @@ -1654,7 +1671,7 @@ mod tests { async fn create_test_safe_outputs() -> (SafeOutputs, tempfile::TempDir) { let temp_dir = tempdir().unwrap(); - let safe_outputs = SafeOutputs::new(temp_dir.path(), temp_dir.path(), None) + let safe_outputs = SafeOutputs::new(temp_dir.path(), temp_dir.path(), None, None) .await .unwrap(); (safe_outputs, temp_dir) @@ -1758,7 +1775,9 @@ mod tests { git(&["add", "."]); git(&["commit", "-q", "-m", "agent change"]); - let base = SafeOutputs::find_merge_base(p).await.expect("resolves base"); + let base = SafeOutputs::find_merge_base(p) + .await + .expect("resolves base"); assert_eq!( base, base_sha, "merge-base must be the origin/main tip resolved via origin/HEAD" @@ -1768,7 +1787,7 @@ mod tests { #[tokio::test] async fn test_new_fails_with_invalid_bounding_directory() { let temp_dir = tempdir().unwrap(); - let result = SafeOutputs::new("/nonexistent/path", temp_dir.path(), None).await; + let result = SafeOutputs::new("/nonexistent/path", temp_dir.path(), None, None).await; assert!(result.is_err()); assert!( @@ -1782,7 +1801,7 @@ mod tests { #[tokio::test] async fn test_new_fails_with_invalid_output_directory() { let temp_dir = tempdir().unwrap(); - let result = SafeOutputs::new(temp_dir.path(), "/nonexistent/path", None).await; + let result = SafeOutputs::new(temp_dir.path(), "/nonexistent/path", None, None).await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("output_directory")); @@ -1957,12 +1976,109 @@ mod tests { assert_eq!(json["context"], "ctx"); } + #[tokio::test] + async fn test_safe_outputs_new_exposes_generated_custom_tools() { + let fm: crate::compile::types::FrontMatter = serde_yaml::from_str( + r#" +name: Test +description: Test +safe-outputs: + scripts: + send-notification: + description: Send a structured notification. + run: node notify.js + inputs: + title: { type: string, required: true, max-length: 120 } +"#, + ) + .unwrap(); + let schemas = crate::compile::custom_tools::generate_custom_tool_schemas(&fm).unwrap(); + let custom_tools_json = crate::compile::custom_tools::custom_tools_json(&schemas).unwrap(); + + let temp_dir = tempfile::tempdir().unwrap(); + let custom_tools_path = temp_dir.path().join("custom-tools.json"); + std::fs::write(&custom_tools_path, custom_tools_json).unwrap(); + + let so = SafeOutputs::new( + temp_dir.path(), + temp_dir.path(), + None, + Some(&custom_tools_path), + ) + .await + .unwrap(); + let tool_names: Vec = so + .tool_router + .list_all() + .iter() + .map(|t| t.name.to_string()) + .collect(); + + assert!(tool_names.contains(&"send-notification".to_string())); + assert!(tool_names.contains(&"noop".to_string())); + assert!(tool_names.contains(&"create-work-item".to_string())); + } + + #[tokio::test] + async fn test_custom_tools_do_not_shadow_builtin_routes() { + let baseline_dir = tempfile::tempdir().unwrap(); + let baseline = SafeOutputs::new(baseline_dir.path(), baseline_dir.path(), None, None) + .await + .unwrap(); + let baseline_count = baseline.tool_router.list_all().len(); + + let temp_dir = tempfile::tempdir().unwrap(); + let custom_tools_path = temp_dir.path().join("custom-tools.json"); + std::fs::write( + &custom_tools_path, + r#"[ + { + "name": "create-work-item", + "description": "custom replacement", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": {} + } + } + ]"#, + ) + .unwrap(); + + let so = SafeOutputs::new( + temp_dir.path(), + temp_dir.path(), + None, + Some(&custom_tools_path), + ) + .await + .unwrap(); + let tools = so.tool_router.list_all(); + assert_eq!(tools.len(), baseline_count); + assert_eq!( + tools + .iter() + .filter(|tool| tool.name.as_ref() == "create-work-item") + .count(), + 1 + ); + let create_work_item = tools + .iter() + .find(|tool| tool.name.as_ref() == "create-work-item") + .unwrap(); + assert_ne!( + create_work_item.description.as_deref(), + Some("custom replacement") + ); + } + // ─── Tool filtering tests ─────────────────────────────────────────── #[tokio::test] async fn test_tool_filtering_none_exposes_all() { let temp_dir = tempfile::tempdir().unwrap(); - let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), None) + let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), None, None) .await .unwrap(); let tools = so.tool_router.list_all(); @@ -1974,7 +2090,7 @@ mod tests { async fn test_tool_filtering_specific_tools() { let temp_dir = tempfile::tempdir().unwrap(); let enabled = vec!["create-pull-request".to_string()]; - let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), Some(&enabled)) + let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), Some(&enabled), None) .await .unwrap(); let tools = so.tool_router.list_all(); @@ -1997,7 +2113,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); // Enable only a tool that doesn't exist — should still have always-on tools let enabled = vec!["nonexistent-tool".to_string()]; - let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), Some(&enabled)) + let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), Some(&enabled), None) .await .unwrap(); let tools = so.tool_router.list_all(); @@ -2020,7 +2136,7 @@ mod tests { "create-work-item".to_string(), "comment-on-work-item".to_string(), ]; - let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), Some(&enabled)) + let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), Some(&enabled), None) .await .unwrap(); let tools = so.tool_router.list_all(); @@ -2063,7 +2179,7 @@ mod tests { // Pass an enable list that includes every debug-only tool so they // remain in the router for this introspection check. let enabled: Vec = DEBUG_ONLY_TOOLS.iter().map(|s| s.to_string()).collect(); - let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), Some(&enabled)) + let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), Some(&enabled), None) .await .unwrap(); let router_tools: Vec = so @@ -2089,7 +2205,7 @@ mod tests { #[tokio::test] async fn test_filter_strips_debug_only_when_no_enabled_list() { let temp_dir = tempfile::tempdir().unwrap(); - let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), None) + let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), None, None) .await .unwrap(); let tool_names: Vec = so @@ -2113,7 +2229,7 @@ mod tests { async fn test_filter_keeps_debug_only_when_explicitly_enabled() { let temp_dir = tempfile::tempdir().unwrap(); let enabled = vec!["create-issue".to_string()]; - let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), Some(&enabled)) + let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), Some(&enabled), None) .await .unwrap(); let tool_names: Vec = so @@ -2133,7 +2249,7 @@ mod tests { async fn test_filter_strips_debug_only_when_other_tool_enabled() { let temp_dir = tempfile::tempdir().unwrap(); let enabled = vec!["create-work-item".to_string()]; - let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), Some(&enabled)) + let so = SafeOutputs::new(temp_dir.path(), temp_dir.path(), Some(&enabled), None) .await .unwrap(); let tool_names: Vec = so diff --git a/src/mcp_custom_tools.rs b/src/mcp_custom_tools.rs new file mode 100644 index 00000000..f2bfd498 --- /dev/null +++ b/src/mcp_custom_tools.rs @@ -0,0 +1,251 @@ +//! Dynamic (config-driven) custom safe-output MCP tools. +//! +//! Built-in safe-output tools are registered statically via the rmcp +//! `#[tool_router]` macro. Custom safe-output tools — declared by an imported +//! component's `safe-outputs.scripts.` / `safe-outputs.jobs.` +//! block (see the reusable-custom-safe-output-jobs feature) — are not known at +//! compile time, so they are surfaced from a **generated schema file** loaded +//! at server startup. +//! +//! The compiler emits a JSON array of tool definitions (name + description + +//! closed input JSON Schema, `additionalProperties: false`) and passes its path +//! via `--custom-tools`. Each entry is registered as a real MCP tool whose +//! generic handler appends a proposal NDJSON line tagged with the tool name — +//! the same `{ "name": , <...args> }` shape the built-in tools produce. +//! Budget, sanitization, and execution are enforced later by the Stage-3 +//! executor; the MCP server only records the proposal. + +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use log::{info, warn}; +use rmcp::handler::server::router::tool::{ToolRoute, ToolRouter}; +use rmcp::handler::server::tool::ToolCallContext; +use rmcp::model::{CallToolResult, Tool}; +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::mcp::SafeOutputs; + +/// A single custom-tool definition as emitted by the compiler. +#[derive(Debug, Clone, Deserialize)] +pub struct CustomToolDef { + /// The MCP tool name (also the proposal `name` tag). + pub name: String, + /// Human-readable description shown to the agent. + #[serde(default)] + pub description: String, + /// Closed JSON Schema for the tool inputs (`additionalProperties: false`). + #[serde(rename = "inputSchema", default)] + pub input_schema: Map, +} + +/// Load custom-tool definitions from a compiler-generated JSON file. +/// +/// The file is a JSON array of [`CustomToolDef`]. A missing file is an error +/// (the caller only passes a path when custom tools were configured). +pub fn load_custom_tool_defs(path: &Path) -> Result> { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read custom-tools file: {}", path.display()))?; + let defs: Vec = serde_json::from_str(&contents) + .with_context(|| format!("Failed to parse custom-tools JSON: {}", path.display()))?; + Ok(defs) +} + +/// Register each custom-tool definition as a dynamic route on `tool_router`. +/// +/// A name that collides with an already-registered (built-in) tool is skipped +/// with a warning — built-ins are never shadowed by a custom tool. +pub fn register_custom_tools(tool_router: &mut ToolRouter, defs: Vec) { + for def in defs { + if tool_router.has_route(&def.name) { + warn!( + "Custom tool '{}' collides with an existing tool; skipping", + def.name + ); + continue; + } + tool_router.add_route(build_custom_route(def)); + } +} + +/// Build a dynamic [`ToolRoute`] whose handler records the agent's inputs as a +/// proposal NDJSON line. +fn build_custom_route(def: CustomToolDef) -> ToolRoute { + let tool_name = def.name.clone(); + let schema: Arc> = Arc::new(def.input_schema); + let tool = Tool::new(def.name.clone(), def.description.clone(), schema); + + ToolRoute::new_dyn(tool, move |ctx: ToolCallContext<'_, SafeOutputs>| { + let tool_name = tool_name.clone(); + let output_path = ctx.service.custom_output_path(); + let arguments = ctx.arguments.clone(); + Box::pin(async move { + if let Err(e) = append_custom_proposal(&output_path, &tool_name, arguments).await { + warn!("Failed to record custom safe-output proposal '{tool_name}': {e:#}"); + } + Ok(CallToolResult::success(vec![])) + }) + }) +} + +/// Append a `{ "name": , <...args> }` proposal line to the NDJSON file. +async fn append_custom_proposal( + output_path: &Path, + tool_name: &str, + arguments: Option>, +) -> Result<()> { + let mut entry = arguments.unwrap_or_default(); + // The `name` tag is compiler-owned and always wins over any agent input. + entry.insert("name".to_string(), Value::String(tool_name.to_string())); + let line = serde_json::to_string(&Value::Object(entry))? + "\n"; + + use tokio::io::AsyncWriteExt; + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(output_path) + .await + .with_context(|| { + format!( + "Failed to open NDJSON for append: {}", + output_path.display() + ) + })?; + file.write_all(line.as_bytes()).await?; + file.flush().await?; + Ok(()) +} + +/// Load and register custom tools from `path`, logging a summary. +pub fn apply_custom_tools(tool_router: &mut ToolRouter, path: &Path) -> Result<()> { + let defs = load_custom_tool_defs(path)?; + let count = defs.len(); + register_custom_tools(tool_router, defs); + info!( + "Registered {count} custom safe-output tool(s) from {}", + path.display() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile::custom_tools::{custom_tools_json, generate_custom_tool_schemas}; + use crate::compile::types::FrontMatter; + + const SAMPLE: &str = r#"[ + { + "name": "send-notification", + "description": "Send a structured notification.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["title"], + "properties": { + "title": { "type": "string" }, + "severity": { "type": "string", "enum": ["info", "warning", "critical"] } + } + } + } + ]"#; + + fn parse_front_matter(yaml: &str) -> FrontMatter { + serde_yaml::from_str(yaml).unwrap() + } + + #[test] + fn test_load_custom_tool_defs() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("custom-tools.json"); + std::fs::write(&path, SAMPLE).unwrap(); + + let defs = load_custom_tool_defs(&path).unwrap(); + assert_eq!(defs.len(), 1); + assert_eq!(defs[0].name, "send-notification"); + assert_eq!(defs[0].description, "Send a structured notification."); + assert_eq!( + defs[0].input_schema["additionalProperties"], + Value::Bool(false) + ); + } + + #[test] + fn test_register_custom_tools_adds_route() { + let defs = serde_json::from_str::>(SAMPLE).unwrap(); + let mut router: ToolRouter = ToolRouter::new(); + register_custom_tools(&mut router, defs); + assert!(router.has_route("send-notification")); + assert_eq!(router.list_all().len(), 1); + } + + #[test] + fn test_generated_custom_tool_json_registers_only_declared_tool() { + let fm = parse_front_matter( + r#" +name: Test +description: Test +safe-outputs: + scripts: + send-notification: + description: Send a structured notification. + run: node notify.js + inputs: + title: { type: string, required: true, max-length: 120 } +"#, + ); + let schemas = generate_custom_tool_schemas(&fm).unwrap(); + assert_eq!(schemas.len(), 1); + assert_eq!(schemas[0].name, "send-notification"); + + let empty_fm = parse_front_matter( + r#" +name: Test +description: Test +"#, + ); + assert!(generate_custom_tool_schemas(&empty_fm).unwrap().is_empty()); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("custom-tools.json"); + std::fs::write(&path, custom_tools_json(&schemas).unwrap()).unwrap(); + let defs = load_custom_tool_defs(&path).unwrap(); + + let mut router: ToolRouter = ToolRouter::new(); + register_custom_tools(&mut router, defs); + assert!(router.has_route("send-notification")); + assert!(!router.has_route("deploy-thing")); + } + + #[test] + fn test_register_skips_duplicate_name() { + let mut router: ToolRouter = ToolRouter::new(); + let defs = serde_json::from_str::>(SAMPLE).unwrap(); + register_custom_tools(&mut router, defs); + // Registering the same name again is a no-op (existing route wins). + let dup = serde_json::from_str::>(SAMPLE).unwrap(); + register_custom_tools(&mut router, dup); + assert_eq!(router.list_all().len(), 1); + } + + #[tokio::test] + async fn test_append_custom_proposal_shape() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("safe_outputs.ndjson"); + let mut args = Map::new(); + args.insert("title".to_string(), Value::String("Outage".to_string())); + // An agent-supplied "name" must be overridden by the compiler-owned tag. + args.insert("name".to_string(), Value::String("spoofed".to_string())); + + append_custom_proposal(&path, "send-notification", Some(args)) + .await + .unwrap(); + + let contents = std::fs::read_to_string(&path).unwrap(); + let v: Value = serde_json::from_str(contents.trim()).unwrap(); + assert_eq!(v["name"], "send-notification"); + assert_eq!(v["title"], "Outage"); + } +} diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 94634c33..d4a72140 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1713,8 +1713,7 @@ Call the noop tool exactly once. ); let agent = extract_job_block(&compiled, "Agent").expect("Agent job should exist"); - let detection = - extract_job_block(&compiled, "Detection").expect("Detection job should exist"); + let detection = extract_job_block(&compiled, "Detection").expect("Detection job should exist"); assert!( agent.contains( @@ -5306,12 +5305,16 @@ fn test_byom_provider_env_compiles_and_merges() { // The compiler-owned in-job mint step runs before the Copilot invocation in // BOTH the Agent and Detection jobs, authenticated by the service connection. assert_eq!( - compiled.matches("displayName: Acquire provider bearer token").count(), + compiled + .matches("displayName: Acquire provider bearer token") + .count(), 2, "provider.token must emit the AzureCLI@2 mint step in both the Agent and Detection jobs: {compiled}" ); assert_eq!( - compiled.matches("azureSubscription: my-arm-connection").count(), + compiled + .matches("azureSubscription: my-arm-connection") + .count(), 2, "mint step must authenticate via the configured service connection in both jobs: {compiled}" ); @@ -5361,14 +5364,18 @@ fn test_byom_provider_env_compiles_and_merges() { // from --env-all in both jobs, so it can never ride the passthrough into the // agent container even if ADO ever exposed it as a process env var. assert_eq!( - compiled.matches("--exclude-env AW_PROVIDER_BEARER_TOKEN").count(), + compiled + .matches("--exclude-env AW_PROVIDER_BEARER_TOKEN") + .count(), 2, "the intermediate mint secret AW_PROVIDER_BEARER_TOKEN must be excluded in both jobs: {compiled}" ); // A credential key NOT configured must NOT be excluded (BEARER_TOKEN is never // used by ado-aw — the sidecar only reads API_KEY). assert_eq!( - compiled.matches("--exclude-env COPILOT_PROVIDER_BEARER_TOKEN").count(), + compiled + .matches("--exclude-env COPILOT_PROVIDER_BEARER_TOKEN") + .count(), 0, "COPILOT_PROVIDER_BEARER_TOKEN must never be referenced: {compiled}" ); @@ -5392,31 +5399,30 @@ fn test_byom_provider_env_compiles_and_merges() { #[test] fn test_byok_provider_api_key_compiles_without_mint_step() { // Reuse the token fixture but swap the `token:` block for a static `api-key`. - let compiled = compile_fixture_tree_with_flags( - "byom-foundry-agent.md", - &[], - &[], - |contents| { - // Line-based rewrite (robust to CRLF/LF): drop the `token:` block and - // insert a static `api-key:` in its place. - let newline = if contents.contains("\r\n") { "\r\n" } else { "\n" }; - contents - .lines() - .filter(|l| { - let t = l.trim(); - t != "token:" && t != "service-connection: my-arm-connection" - }) - .map(|l| { - if l.trim() == "type: azure" { - format!("{l}{newline} api-key: $(FOUNDRY_API_KEY)") - } else { - l.to_string() - } - }) - .collect::>() - .join(newline) - }, - ); + let compiled = compile_fixture_tree_with_flags("byom-foundry-agent.md", &[], &[], |contents| { + // Line-based rewrite (robust to CRLF/LF): drop the `token:` block and + // insert a static `api-key:` in its place. + let newline = if contents.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + contents + .lines() + .filter(|l| { + let t = l.trim(); + t != "token:" && t != "service-connection: my-arm-connection" + }) + .map(|l| { + if l.trim() == "type: azure" { + format!("{l}{newline} api-key: $(FOUNDRY_API_KEY)") + } else { + l.to_string() + } + }) + .collect::>() + .join(newline) + }); assert_valid_yaml(&compiled, "byom-foundry-agent.md (api-key)"); // api-key maps to COPILOT_PROVIDER_API_KEY in both jobs (unquoted macro). @@ -5443,7 +5449,9 @@ fn test_byok_provider_api_key_compiles_without_mint_step() { "the api-key path must still enable the api-proxy sidecar in both jobs: {compiled}" ); assert_eq!( - compiled.matches("--exclude-env COPILOT_PROVIDER_API_KEY").count(), + compiled + .matches("--exclude-env COPILOT_PROVIDER_API_KEY") + .count(), 2, "the api-key credential must be excluded from --env-all in both jobs: {compiled}" ); @@ -8150,13 +8158,28 @@ fn variable_groups_standalone_emits_group_imports_in_order() { "vg-standalone", "---\nname: vg-standalone\ndescription: variable group import test\nvariable-groups:\n - Agentic Workflows\n - Shared Secrets\n---\n\n## Agent\n\nDo work.\n", ); - assert!(ok, "standalone compile with variable-groups should succeed.\nstderr: {stderr}"); - assert!(compiled.contains("variables:"), "top-level variables: block must be present:\n{compiled}"); - assert!(compiled.contains("- group: Agentic Workflows"), "first group import missing:\n{compiled}"); - assert!(compiled.contains("- group: Shared Secrets"), "second group import missing:\n{compiled}"); + assert!( + ok, + "standalone compile with variable-groups should succeed.\nstderr: {stderr}" + ); + assert!( + compiled.contains("variables:"), + "top-level variables: block must be present:\n{compiled}" + ); + assert!( + compiled.contains("- group: Agentic Workflows"), + "first group import missing:\n{compiled}" + ); + assert!( + compiled.contains("- group: Shared Secrets"), + "second group import missing:\n{compiled}" + ); let first = compiled.find("Agentic Workflows").unwrap(); let second = compiled.find("Shared Secrets").unwrap(); - assert!(first < second, "group imports must preserve declaration order"); + assert!( + first < second, + "group imports must preserve declaration order" + ); } /// A 1ES pipeline (`target: 1es`) also emits the `variables:` group import at @@ -8167,10 +8190,22 @@ fn variable_groups_onees_emits_group_imports() { "vg-onees", "---\nname: vg-onees\ndescription: variable group import test\ntarget: 1es\nvariable-groups:\n - Agentic Workflows\n---\n\n## Agent\n\nDo work.\n", ); - assert!(ok, "1es compile with variable-groups should succeed.\nstderr: {stderr}"); - assert!(compiled.contains("extends:"), "1ES pipeline must still emit extends:\n{compiled}"); - assert!(compiled.contains("variables:"), "top-level variables: block must be present:\n{compiled}"); - assert!(compiled.contains("- group: Agentic Workflows"), "group import missing:\n{compiled}"); + assert!( + ok, + "1es compile with variable-groups should succeed.\nstderr: {stderr}" + ); + assert!( + compiled.contains("extends:"), + "1ES pipeline must still emit extends:\n{compiled}" + ); + assert!( + compiled.contains("variables:"), + "top-level variables: block must be present:\n{compiled}" + ); + assert!( + compiled.contains("- group: Agentic Workflows"), + "group import missing:\n{compiled}" + ); } /// `target: job` cannot carry pipeline-level `variables:`, so a non-empty @@ -8182,8 +8217,14 @@ fn variable_groups_rejected_for_job_target() { "---\nname: vg-job\ndescription: variable group import test\ntarget: job\nvariable-groups:\n - Agentic Workflows\n---\n\n## Agent\n\nDo work.\n", ); assert!(!ok, "job target with variable-groups must fail to compile"); - assert!(stderr.contains("variable-groups"), "error must mention variable-groups:\n{stderr}"); - assert!(stderr.contains("target: job"), "error must name the job target:\n{stderr}"); + assert!( + stderr.contains("variable-groups"), + "error must mention variable-groups:\n{stderr}" + ); + assert!( + stderr.contains("target: job"), + "error must name the job target:\n{stderr}" + ); } /// `target: stage` is rejected for the same reason as `target: job`. @@ -8193,9 +8234,18 @@ fn variable_groups_rejected_for_stage_target() { "vg-stage", "---\nname: vg-stage\ndescription: variable group import test\ntarget: stage\nvariable-groups:\n - Agentic Workflows\n---\n\n## Agent\n\nDo work.\n", ); - assert!(!ok, "stage target with variable-groups must fail to compile"); - assert!(stderr.contains("variable-groups"), "error must mention variable-groups:\n{stderr}"); - assert!(stderr.contains("target: stage"), "error must name the stage target:\n{stderr}"); + assert!( + !ok, + "stage target with variable-groups must fail to compile" + ); + assert!( + stderr.contains("variable-groups"), + "error must mention variable-groups:\n{stderr}" + ); + assert!( + stderr.contains("target: stage"), + "error must name the stage target:\n{stderr}" + ); } /// A group name that carries an ADO macro expression is rejected as an unsafe @@ -8207,8 +8257,10 @@ fn variable_groups_rejects_injection_name() { "---\nname: vg-inject\ndescription: variable group import test\nvariable-groups:\n - \"$(evil)\"\n---\n\n## Agent\n\nDo work.\n", ); assert!(!ok, "an injection-bearing group name must fail to compile"); - assert!(stderr.contains("variable group name") || stderr.contains("variable-groups entry"), - "error must explain the invalid group name:\n{stderr}"); + assert!( + stderr.contains("variable group name") || stderr.contains("variable-groups entry"), + "error must explain the invalid group name:\n{stderr}" + ); } /// A workflow that references a GitHub App private key held in a project-level @@ -8221,10 +8273,18 @@ fn variable_groups_with_github_app_token_compiles_without_patch() { "vg-ghapp", "---\nname: vg-ghapp\ndescription: variable group + github app token\nvariable-groups:\n - Agentic Workflows\nengine:\n id: copilot\n github-app-token:\n app-id: 1234567\n owner: octo-org\n repositories: [octo-repo]\n private-key: AGENTIC_WORKFLOWS_GITHUB_APP_PRIVATE_KEY\n---\n\n## Agent\n\nDo work.\n", ); - assert!(ok, "compile with variable-groups + github-app-token should succeed.\nstderr: {stderr}"); - assert!(compiled.contains("- group: Agentic Workflows"), "group import missing:\n{compiled}"); - assert!(compiled.contains("$(AGENTIC_WORKFLOWS_GITHUB_APP_PRIVATE_KEY)"), - "private-key macro reference missing:\n{compiled}"); + assert!( + ok, + "compile with variable-groups + github-app-token should succeed.\nstderr: {stderr}" + ); + assert!( + compiled.contains("- group: Agentic Workflows"), + "group import missing:\n{compiled}" + ); + assert!( + compiled.contains("$(AGENTIC_WORKFLOWS_GITHUB_APP_PRIVATE_KEY)"), + "private-key macro reference missing:\n{compiled}" + ); } // ───────────────────────────────────────────────────────────────────── @@ -8405,8 +8465,7 @@ fn test_create_pull_request_safeoutputs_prepare_step_covers_all_checkout_repos() ); let safeoutputs = job_block(&compiled, "SafeOutputs"); assert!( - safeoutputs - .contains("--repo-dir \"$(Build.SourcesDirectory)\" --target-branch 'main'"), + safeoutputs.contains("--repo-dir \"$(Build.SourcesDirectory)\" --target-branch 'main'"), "self must target the literal default 'main' in the SafeOutputs job:\n{safeoutputs}" ); assert!( @@ -8493,3 +8552,96 @@ fn test_no_create_pull_request_omits_prepare_pr_base_step() { "prepare step display name must be absent:\n{compiled}" ); } + +// ─── Custom safe-output (imports/scripts/jobs) acceptance matrix (#1473) ───── + +/// Compile the custom scripts-style fixture with the front-matter `target:` +/// swapped to `target`, returning the compiled YAML. +fn compile_custom_for_target(target: Option<&str>) -> String { + let target_owned = target.map(str::to_string); + compile_fixture_tree_with_flags( + "custom-safe-output-scripts.md", + &[], + &["--skip-integrity"], + move |contents| match &target_owned { + Some(t) => contents.replacen( + "description: A workflow", + &format!("target: {t}\ndescription: A workflow"), + 1, + ), + None => contents, + }, + ) +} + +#[test] +fn custom_safe_output_emits_gated_executor_job_standalone() { + let compiled = compile_custom_for_target(None); + assert_valid_yaml(&compiled, "custom-safe-output-scripts.md"); + // A dedicated per-definition custom job is emitted. + assert!( + compiled.contains("Custom_send_notification"), + "expected a Custom_send_notification job:\n{compiled}" + ); + // It invokes the executor via the scripts-style --custom-config contract. + assert!( + compiled.contains("--custom-config"), + "expected the custom job to call `ado-aw execute --custom-config`:\n{compiled}" + ); + // The generated closed MCP tool schema is wired into the server launch. + assert!( + compiled.contains("--custom-tools"), + "expected the SafeOutputs MCP server to receive --custom-tools:\n{compiled}" + ); + assert!( + compiled.contains("\"additionalProperties\":false"), + "expected a closed (additionalProperties:false) generated schema:\n{compiled}" + ); + // require-approval on the custom tool routes it through ManualReview. + assert!( + compiled.contains("- job: ManualReview"), + "reviewed custom tool must emit a ManualReview gate:\n{compiled}" + ); + assert!( + compiled.contains("HasCustom_send_notification"), + "Detection must publish a per-tool proposal signal:\n{compiled}" + ); +} + +#[test] +fn custom_safe_output_compiles_for_all_targets() { + for target in [None, Some("1es"), Some("job"), Some("stage")] { + let compiled = compile_custom_for_target(target); + let label = target.unwrap_or("standalone"); + assert_valid_yaml(&compiled, &format!("custom-safe-output-scripts.md ({label})")); + assert!( + compiled.contains("Custom_send_notification"), + "target {label}: expected a Custom_send_notification job:\n{compiled}" + ); + } +} + +#[test] +fn custom_safe_output_secret_scope_excludes_agent_and_detection() { + // The generated custom-tools schema (agent-facing) must NOT leak the + // executor contract into the Agent job beyond the closed input schema: + // the executor `--custom-config` / `--custom-phase` invocations belong only + // to the dedicated custom job, never the Agent or Detection jobs. + let compiled = compile_custom_for_target(None); + // Locate the Agent and Detection job bodies and assert they don't invoke + // the custom executor modes. + for marker in ["--custom-config", "--custom-phase"] { + // The only occurrences must be inside the Custom_ job. A crude but + // effective check: every line containing the marker must be part of a + // custom job region (which begins at `Custom_send_notification`). + let custom_start = compiled + .find("Custom_send_notification") + .expect("custom job present"); + for (idx, _) in compiled.match_indices(marker) { + assert!( + idx > custom_start, + "executor marker {marker} must only appear in the custom job region" + ); + } + } +} diff --git a/tests/fixtures/custom-safe-output-scripts.md b/tests/fixtures/custom-safe-output-scripts.md new file mode 100644 index 00000000..01926ef1 --- /dev/null +++ b/tests/fixtures/custom-safe-output-scripts.md @@ -0,0 +1,23 @@ +--- +name: Custom Safe Output Acceptance +description: A workflow with a custom scripts-style safe-output tool for the acceptance matrix. +safe-outputs: + scripts: + send-notification: + description: Send a structured notification to the configured destination. + run: node send-notification.js + max: 3 + inputs: + title: + type: string + required: true + max-length: 120 + severity: + type: choice + options: [info, warning, critical] + required: true + send-notification: + require-approval: true +--- + +Analyze the run and call `send-notification` only when a person should act. From c5cc0287854e2679b5b74a14790041da912281fb Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 14 Jul 2026 11:01:58 +0100 Subject: [PATCH 02/12] fix(compile): address Rust PR review feedback on imports - resolve_local_path: reject path-traversal (`..`/`.`/empty segments and backslashes) so a local import cannot escape the workflow directory at compile time, matching the guard already applied to remote import paths - extract_markdown_section: replace a production `.expect()` with a fail-closed `ok_or_else(..)?` so a future refactor can't panic - render_json_value: run string import inputs through `sanitize_config` (neutralizes `##vso[` / `##[` pipeline commands, strips control chars) as defense-in-depth, and document the compile-time-author-choice trust boundary Adds regression tests for the traversal guard and pipeline-command neutralization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f --- src/compile/imports/mod.rs | 37 ++++++++++++++++++++++++++++++++++- src/compile/imports/schema.rs | 34 +++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/compile/imports/mod.rs b/src/compile/imports/mod.rs index 73a8b25e..3a659a45 100644 --- a/src/compile/imports/mod.rs +++ b/src/compile/imports/mod.rs @@ -217,6 +217,21 @@ fn resolve_local_path(base_dir: &Path, import_path: &str) -> Result { if path.is_absolute() { anyhow::bail!("local import path must be relative, got `{}`", import_path); } + // Reject path-traversal: a `..`/`.` segment (or a backslash) would let a + // local import escape the workflow directory and read arbitrary files at + // compile time. Mirrors the guard `flatten_import_path` applies to remote + // import paths. + if import_path.contains('\\') + || import_path + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + { + anyhow::bail!( + "local import path `{}` contains an invalid segment; `.`, `..`, empty \ + segments, and backslashes are not allowed", + import_path + ); + } Ok(base_dir.join(path)) } @@ -380,7 +395,9 @@ fn extract_markdown_section(body: &str, section: &str) -> Result { .ok_or_else(|| anyhow::anyhow!("import section `{}` was not found", section))?; let start_level = markdown_heading(lines[start]) .map(|(level, _)| level) - .expect("line matched heading above"); + .ok_or_else(|| { + anyhow::anyhow!("import section `{}` heading could not be re-parsed", section) + })?; let end = lines .iter() @@ -603,6 +620,24 @@ mod tests { ); } + #[test] + fn local_import_rejects_path_traversal() { + let repo = tempfile::tempdir().unwrap(); + for spec in ["../secret.md", "../../etc/passwd.md", "a/../../b.md", "./x.md"] { + let err = resolve_imports_with_repo_root( + &[import_entry(spec)], + repo.path(), + repo.path(), + &PanicFetcher, + ) + .unwrap_err(); + assert!( + format!("{err:#}").contains("invalid segment"), + "spec `{spec}` should be rejected as traversal, got: {err:#}" + ); + } + } + #[test] fn imports_per_workflow_limit_is_enforced() { let repo = tempfile::tempdir().unwrap(); diff --git a/src/compile/imports/schema.rs b/src/compile/imports/schema.rs index f69beca7..4edd5f6c 100644 --- a/src/compile/imports/schema.rs +++ b/src/compile/imports/schema.rs @@ -429,9 +429,22 @@ fn lookup_input_path<'a>( Some(value) } +/// Render an import-input value for interpolation into a `${{ ... }}` +/// placeholder. +/// +/// **Trust boundary.** Import inputs are non-secret, compile-time author +/// choices: a component's schema defaults are pinned by commit SHA (reviewed at +/// import time) and a consumer's `with:` values are committed to the consumer +/// repo (reviewed at author time). Neither is agent- or runtime-controlled. +/// As defense-in-depth we still run string values through +/// [`crate::sanitize::sanitize_config`], which neutralizes Azure DevOps +/// pipeline logging commands (`##vso[` / `##[`) and strips control characters, +/// so an interpolated value can never smuggle a pipeline command into a +/// generated step. Non-string scalars/containers are rendered structurally and +/// need no neutralization. fn render_json_value(value: &JsonValue) -> String { match value { - JsonValue::String(value) => value.clone(), + JsonValue::String(value) => crate::sanitize::sanitize_config(value), JsonValue::Number(value) => value.to_string(), JsonValue::Bool(value) => value.to_string(), JsonValue::Array(_) | JsonValue::Object(_) => { @@ -650,6 +663,25 @@ import-schema: ); } + #[test] + fn substitute_inputs_neutralizes_pipeline_commands_in_string_values() { + // A string import input containing an ADO logging command must be + // neutralized when interpolated (defense-in-depth), so it cannot smuggle + // a `##vso[` pipeline command into a generated step. + let inputs = json!({ "evil": "##vso[task.setvariable variable=x]y" }); + let substituted = substitute_inputs("val=${{ ado.aw.import-inputs.evil }}", inputs.as_object().unwrap()); + // The neutralized form wraps the command in backticks so ADO renders it + // as inert text instead of executing it. + assert!( + substituted.contains("`##vso[`"), + "expected neutralized (backtick-wrapped) form: {substituted}" + ); + assert!( + !substituted.contains("##vso[task.setvariable"), + "the executable command tail must be broken up: {substituted}" + ); + } + #[test] fn substitute_front_matter_walks_nested_mappings_and_sequences() { let fm = yaml( From 8985edb5e9b9591081ad86d76200a317797b09bd Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 14 Jul 2026 14:12:01 +0100 Subject: [PATCH 03/12] fix(compile): harden custom safe-output component checkout and executor Address in-depth + Rust review findings on PR #1508. - HIGH: SHA-pinned component checkout no longer relies on a shallow fetch of `main` (which lacks the pinned object once main advances). Add a new `checkout-component` ado-script bundle that fetches the pinned commit (direct `git fetch origin `, then progressive deepening) over the ADO bearer, checks it out detached, and verifies HEAD == pin, failing closed. Replaces the raw-bash verify step. - MEDIUM: `run_custom_entrypoint` now drives the component subprocess with `tokio::process` and writes stdin concurrently with draining stdout/stderr, removing the blocked-runtime-thread and large-payload pipe-deadlock risks. - LOW: reject a component's own nested `imports:` with a clear error instead of silently dropping it; verify the committed manifest cache against a `.sha256` digest sidecar on read (tamper detection); derive the staged execution-record status from explicit state instead of matching a message prefix; de-duplicate the `yaml_value_kind` helper across the imports modules. Adds TS bundle tests, bundle-coverage/gitignore wiring, and Rust regression tests (bundle emission, nested-import rejection, cache-tamper detection). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f --- scripts/ado-script/.gitignore | 1 + scripts/ado-script/package.json | 3 +- .../__tests__/index.test.ts | 135 ++++++++++++ .../src/checkout-component/index.ts | 193 ++++++++++++++++++ src/compile/ado_bundle.rs | 13 +- src/compile/agentic_pipeline.rs | 46 +++-- src/compile/extensions/ado_script.rs | 37 +++- src/compile/imports/mod.rs | 105 +++++++++- src/compile/imports/schema.rs | 18 +- src/execute.rs | 39 ++-- 10 files changed, 533 insertions(+), 57 deletions(-) create mode 100644 scripts/ado-script/src/checkout-component/__tests__/index.test.ts create mode 100644 scripts/ado-script/src/checkout-component/index.ts diff --git a/scripts/ado-script/.gitignore b/scripts/ado-script/.gitignore index f50cd05e..905b1521 100644 --- a/scripts/ado-script/.gitignore +++ b/scripts/ado-script/.gitignore @@ -15,6 +15,7 @@ approval-summary.js conclusion.js github-app-token.js prepare-pr-base.js +checkout-component.js schema *.tsbuildinfo test-bin diff --git a/scripts/ado-script/package.json b/scripts/ado-script/package.json index d52b039a..af7dfb82 100644 --- a/scripts/ado-script/package.json +++ b/scripts/ado-script/package.json @@ -7,7 +7,7 @@ "node": ">=20.0.0" }, "scripts": { - "build": "npm run codegen && npm run clean && npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && npm run build:exec-context-manual && npm run build:exec-context-pipeline && npm run build:exec-context-ci-push && npm run build:exec-context-workitem && npm run build:exec-context-schedule && npm run build:exec-context-pr-checks && npm run build:exec-context-repo && npm run build:conclusion && npm run build:approval-summary && npm run build:github-app-token && npm run build:prepare-pr-base", + "build": "npm run codegen && npm run clean && npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && npm run build:exec-context-manual && npm run build:exec-context-pipeline && npm run build:exec-context-ci-push && npm run build:exec-context-workitem && npm run build:exec-context-schedule && npm run build:exec-context-pr-checks && npm run build:exec-context-repo && npm run build:conclusion && npm run build:approval-summary && npm run build:github-app-token && npm run build:prepare-pr-base && npm run build:checkout-component", "clean": "node -e \"const fs=require('node:fs'); fs.rmSync('.ado-build',{recursive:true,force:true}); for (const n of ['gate','import','exec-context-pr','exec-context-pr-synth','exec-context-manual','exec-context-pipeline','exec-context-ci-push','exec-context-workitem','exec-context-schedule','exec-context-pr-checks','exec-context-repo','conclusion','approval-summary','github-app-token','prepare-pr-base']) fs.rmSync(n+'.js',{force:true});\"", "build:gate": "ncc build src/gate/index.ts -o .ado-build/gate -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/gate/index.js','gate.js'); fs.rmSync('.ado-build/gate',{recursive:true,force:true});\"", "build:import": "ncc build src/import/index.ts -o .ado-build/import -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/import/index.js','import.js'); fs.rmSync('.ado-build/import',{recursive:true,force:true});\"", @@ -24,6 +24,7 @@ "build:approval-summary": "ncc build src/approval-summary/index.ts -o .ado-build/approval-summary -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/approval-summary/index.js','approval-summary.js'); fs.rmSync('.ado-build/approval-summary',{recursive:true,force:true});\"", "build:github-app-token": "ncc build src/github-app-token/index.ts -o .ado-build/github-app-token -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/github-app-token/index.js','github-app-token.js'); fs.rmSync('.ado-build/github-app-token',{recursive:true,force:true});\"", "build:prepare-pr-base": "ncc build src/prepare-pr-base/index.ts -o .ado-build/prepare-pr-base -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/prepare-pr-base/index.js','prepare-pr-base.js'); fs.rmSync('.ado-build/prepare-pr-base',{recursive:true,force:true});\"", + "build:checkout-component": "ncc build src/checkout-component/index.ts -o .ado-build/checkout-component -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/checkout-component/index.js','checkout-component.js'); fs.rmSync('.ado-build/checkout-component',{recursive:true,force:true});\"", "build:executor-e2e": "ncc build src/executor-e2e/index.ts -o .ado-build/executor-e2e -m -t && node -e \"const fs=require('node:fs'); fs.mkdirSync('test-bin',{recursive:true}); fs.copyFileSync('.ado-build/executor-e2e/index.js','test-bin/executor-e2e.js'); fs.rmSync('.ado-build/executor-e2e',{recursive:true,force:true});\"", "build:trigger-e2e": "ncc build src/trigger-e2e/index.ts -o .ado-build/trigger-e2e -m -t && node -e \"const fs=require('node:fs'); fs.mkdirSync('test-bin',{recursive:true}); fs.copyFileSync('.ado-build/trigger-e2e/index.js','test-bin/trigger-e2e.js'); fs.rmSync('.ado-build/trigger-e2e',{recursive:true,force:true});\"", "build:check": "ls -lh gate.js && wc -c gate.js", diff --git a/scripts/ado-script/src/checkout-component/__tests__/index.test.ts b/scripts/ado-script/src/checkout-component/__tests__/index.test.ts new file mode 100644 index 00000000..d5d6bda1 --- /dev/null +++ b/scripts/ado-script/src/checkout-component/__tests__/index.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import type { GitResult } from "../../shared/git.js"; +import { main, parseArgs, type GitRunners } from "../index.js"; + +const SHA = "a".repeat(40); +const OTHER = "b".repeat(40); + +/** + * Build a scriptable `GitRunners` pair. `present` decides, per invocation + * count, whether `git cat-file -e ^{commit}` reports the object present + * (status 0) — so a test can model "absent until the Nth fetch". `checkout` + * and `head` control the detach + rev-parse outcomes. + */ +function makeRunners(opts: { + /** Return codes for successive `cat-file -e` probes (default: always 1/absent). */ + catFile?: number[]; + fetchStatus?: number; + checkoutStatus?: number; + head?: string; +}): { runners: GitRunners; calls: string[][] } { + const calls: string[][] = []; + let catIdx = 0; + const runGit: GitRunners["runGit"] = (args) => { + calls.push(args); + let result: GitResult = { stdout: "", stderr: "", status: 0 }; + if (args[0] === "cat-file") { + const seq = opts.catFile ?? [1]; + const status = (catIdx < seq.length ? seq[catIdx] : seq[seq.length - 1]) ?? 1; + catIdx++; + result = { stdout: "", stderr: "", status }; + } else if (args[0] === "fetch") { + result = { stdout: "", stderr: "", status: opts.fetchStatus ?? 0 }; + } else if (args[0] === "checkout") { + result = { stdout: "", stderr: "", status: opts.checkoutStatus ?? 0 }; + } + return result; + }; + const gitOk: GitRunners["gitOk"] = (args) => { + if (args[0] === "rev-parse") return opts.head ?? SHA; + return null; + }; + return { runners: { runGit, gitOk }, calls }; +} + +describe("parseArgs", () => { + it("parses --dir and --sha", () => { + expect(parseArgs(["--dir", "/src/comp", "--sha", SHA])).toEqual({ + dir: "/src/comp", + sha: SHA, + }); + }); + + it("defaults to empty strings when flags are absent", () => { + expect(parseArgs([])).toEqual({ dir: "", sha: "" }); + }); +}); + +describe("main", () => { + const okEnv = { SYSTEM_ACCESSTOKEN: "tok" } as NodeJS.ProcessEnv; + const noopChdir = () => {}; + + it("rejects a non-40-char sha (fail closed)", () => { + const { runners, calls } = makeRunners({}); + expect(main({ dir: "/c", sha: "main" }, okEnv, runners, noopChdir)).toBe(1); + // Must not touch git for an invalid pin. + expect(calls).toEqual([]); + }); + + it("requires --dir", () => { + const { runners } = makeRunners({}); + expect(main({ dir: "", sha: SHA }, okEnv, runners, noopChdir)).toBe(1); + }); + + it("fails closed when the component dir cannot be entered", () => { + const { runners } = makeRunners({}); + const throwingChdir = () => { + throw new Error("no such dir"); + }; + expect(main({ dir: "/missing", sha: SHA }, okEnv, runners, throwingChdir)).toBe(1); + }); + + it("checks out and verifies when the sha is already present (no fetch)", () => { + const { runners, calls } = makeRunners({ catFile: [0], head: SHA }); + expect(main({ dir: "/c", sha: SHA }, okEnv, runners, noopChdir)).toBe(0); + expect(calls.some((c) => c[0] === "fetch")).toBe(false); + expect(calls).toContainEqual(["checkout", "--detach", SHA]); + }); + + it("does a direct by-sha fetch when the sha is initially absent", () => { + // absent, then present after the direct fetch. + const { runners, calls } = makeRunners({ catFile: [1, 0], head: SHA }); + expect(main({ dir: "/c", sha: SHA }, okEnv, runners, noopChdir)).toBe(0); + expect(calls).toContainEqual(["fetch", "--no-tags", "--depth", "1", "origin", SHA]); + }); + + it("falls back to progressive deepening when by-sha fetch does not yield the object", () => { + // absent, absent after direct fetch, present after first deepen. + const { runners, calls } = makeRunners({ catFile: [1, 1, 0], head: SHA }); + expect(main({ dir: "/c", sha: SHA }, okEnv, runners, noopChdir)).toBe(0); + expect(calls).toContainEqual(["fetch", "--no-tags", "--depth=200", "origin"]); + }); + + it("fails closed when the sha can never be obtained", () => { + const { runners, calls } = makeRunners({ catFile: [1] }); // always absent + expect(main({ dir: "/c", sha: SHA }, okEnv, runners, noopChdir)).toBe(1); + // Never attempts the checkout of an unavailable object. + expect(calls.some((c) => c[0] === "checkout")).toBe(false); + }); + + it("fails closed when checkout fails", () => { + const { runners } = makeRunners({ catFile: [0], checkoutStatus: 1 }); + expect(main({ dir: "/c", sha: SHA }, okEnv, runners, noopChdir)).toBe(1); + }); + + it("fails closed when HEAD does not equal the pin after checkout", () => { + const { runners } = makeRunners({ catFile: [0], head: OTHER }); + expect(main({ dir: "/c", sha: SHA }, okEnv, runners, noopChdir)).toBe(1); + }); + + it("passes the bearer env to git fetch (never on argv)", () => { + const seen: Array | undefined> = []; + const runGit: GitRunners["runGit"] = (args, env) => { + if (args[0] === "fetch") seen.push(env); + // absent, then present after direct fetch. + const status = args[0] === "cat-file" ? (seen.length === 0 ? 1 : 0) : 0; + return { stdout: "", stderr: "", status }; + }; + const gitOk: GitRunners["gitOk"] = () => SHA; + main({ dir: "/c", sha: SHA }, okEnv, { runGit, gitOk }, noopChdir); + expect(seen.length).toBeGreaterThan(0); + // Bearer is delivered via GIT_CONFIG_* env, and the token is never in argv. + expect(seen[0]?.GIT_CONFIG_VALUE_0).toContain("bearer tok"); + }); +}); diff --git a/scripts/ado-script/src/checkout-component/index.ts b/scripts/ado-script/src/checkout-component/index.ts new file mode 100644 index 00000000..fa6e7286 --- /dev/null +++ b/scripts/ado-script/src/checkout-component/index.ts @@ -0,0 +1,193 @@ +/** + * checkout-component — make a SHA-pinned custom safe-output component available + * at the exact pinned commit on a shallow-default Azure DevOps agent pool. + * + * ## Why this exists + * + * A cross-repository custom safe-output component is checked out via an ADO + * repository resource, whose `ref` can only be a branch/tag — never a commit + * SHA (an ADO limitation). On a shallow-default pool the resource checkout + * pulls only the tip of `refs/heads/main` (`fetchDepth: 1`), so the pinned + * commit object is usually absent and a plain `git checkout --detach ` + * fails with `fatal: reference is not a tree`. That defeats the whole point of + * SHA-pinning: the pinned revision must actually run, reproducibly, regardless + * of where `main` has since moved. + * + * This bundle runs as a **credentialed step in the isolated custom + * safe-output job** (using `$(System.AccessToken)`). It obtains the pinned + * commit object — first via a direct `git fetch origin ` (supported by + * GitHub, GitHub Enterprise, and Azure Repos), then, if the server refuses a + * by-SHA fetch, by progressively deepening the checked-out branch until the + * object is present — then checks it out detached and **verifies HEAD equals + * the pin, failing closed** on any mismatch or unrecoverable fetch. + * + * ## Trust boundary + * + * Mirrors the other credentialed bundles: the bearer (`SYSTEM_ACCESSTOKEN`) is + * passed to the spawned `git` child via `GIT_CONFIG_*` env vars (see + * `shared/git.ts::bearerEnv`) — never in argv, never written to `.git/config`. + * The compiler-owned, non-secret `--dir` / `--sha` are argv flags (immune to + * ADO pipeline-variable shadowing). + * + * ## Posture — FAIL CLOSED + * + * Unlike `prepare-pr-base` (which is a best-effort optimization and exits 0 on + * failure), this bundle is a **security control**: if the exact pinned commit + * cannot be obtained and verified, it exits non-zero so the custom job — and + * the pipeline — fails rather than running an unverified revision. + * + * Invocation: node checkout-component.js --dir --sha <40-hex> + * env: SYSTEM_ACCESSTOKEN (bearer for the git fetch) + */ +import { + bearerEnv, + gitOk as defaultGitOk, + runGit as defaultRunGit, + type GitResult, +} from "../shared/git.js"; + +const SHA40_RE = /^[0-9a-f]{40}$/i; + +/** Injectable git runners (production uses the real ones; tests stub them). */ +export type GitRunners = { + runGit: (args: string[], env?: Record) => GitResult; + gitOk: (args: string[], env?: Record) => string | null; +}; + +const defaultRunners: GitRunners = { + runGit: defaultRunGit, + gitOk: defaultGitOk, +}; + +export interface CheckoutArgs { + /** The component checkout directory (as the compiler resolved it). */ + dir: string; + /** The full 40-char commit SHA the component is pinned to. */ + sha: string; +} + +/** Parse `--dir ` / `--sha <40-hex>` flags. */ +export function parseArgs(argv: string[]): CheckoutArgs { + let dir = ""; + let sha = ""; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--dir") { + dir = argv[i + 1] ?? ""; + i++; + } else if (argv[i] === "--sha") { + sha = argv[i + 1] ?? ""; + i++; + } + } + return { dir, sha }; +} + +/** True when the pinned commit object is already present locally. */ +function shaPresent(sha: string, runners: GitRunners): boolean { + return runners.runGit(["cat-file", "-e", `${sha}^{commit}`]).status === 0; +} + +/** + * Obtain the pinned commit object in the current working directory. Tries a + * direct by-SHA fetch first, then progressively deepens the existing shallow + * history until the object appears. Returns `true` once the object is present. + */ +function ensureShaFetched( + sha: string, + fetchEnv: Record, + runners: GitRunners, +): boolean { + if (shaPresent(sha, runners)) { + return true; + } + + // 1. Direct by-SHA fetch (GitHub / GHE / Azure Repos support this). + runners.runGit(["fetch", "--no-tags", "--depth", "1", "origin", sha], fetchEnv); + if (shaPresent(sha, runners)) { + return true; + } + + // 2. Fall back to progressively deepening the checked-out history until the + // pinned object is reachable (servers that refuse by-SHA fetch). + const depths = ["--depth=200", "--depth=500", "--depth=2000", "--unshallow"]; + for (const depthArg of depths) { + runners.runGit(["fetch", "--no-tags", depthArg, "origin"], fetchEnv); + if (shaPresent(sha, runners)) { + return true; + } + } + + return false; +} + +export function main( + args: CheckoutArgs, + env: NodeJS.ProcessEnv = process.env, + runners: GitRunners = defaultRunners, + chdir: (dir: string) => void = process.chdir.bind(process), +): number { + const { dir, sha } = args; + + if (!SHA40_RE.test(sha)) { + process.stderr.write( + `[checkout-component] error: '--sha' must be a full 40-character commit SHA, got '${sha}'.\n`, + ); + return 1; + } + if (dir.length === 0) { + process.stderr.write("[checkout-component] error: '--dir' is required.\n"); + return 1; + } + + try { + chdir(dir); + } catch (err) { + // The pipeline just checked this dir out; a missing/unusable dir is a real + // infra error, not a benign skip — fail closed. + process.stderr.write( + `[checkout-component] error: could not enter component dir '${dir}': ${(err as Error).message}.\n`, + ); + return 1; + } + + const fetchEnv = bearerEnv(env.SYSTEM_ACCESSTOKEN); + + if (!ensureShaFetched(sha, fetchEnv, runners)) { + process.stderr.write( + `[checkout-component] error: could not obtain pinned commit ${sha} in '${dir}' ` + + "after a direct fetch and progressive deepening.\n", + ); + return 1; + } + + const checkout = runners.runGit(["checkout", "--detach", sha]); + if (checkout.status !== 0) { + process.stderr.write( + `[checkout-component] error: 'git checkout --detach ${sha}' failed in '${dir}': ${checkout.stderr.trim()}.\n`, + ); + return 1; + } + + const actual = runners.gitOk(["rev-parse", "HEAD"]) ?? ""; + if (actual.toLowerCase() !== sha.toLowerCase()) { + process.stderr.write( + `[checkout-component] error: checkout resolved '${actual}', expected pinned '${sha}' in '${dir}'.\n`, + ); + return 1; + } + + process.stdout.write( + `[checkout-component] verified component checkout at ${actual} in '${dir}'.\n`, + ); + return 0; +} + +// CLI entry guard: only run when invoked directly (not when imported by tests). +if ( + typeof process !== "undefined" && + process.argv[1] && + /checkout-component(\/index)?\.js$/.test(process.argv[1]) +) { + const args = parseArgs(process.argv.slice(2)); + process.exit(main(args)); +} diff --git a/src/compile/ado_bundle.rs b/src/compile/ado_bundle.rs index 8533325a..7706555f 100644 --- a/src/compile/ado_bundle.rs +++ b/src/compile/ado_bundle.rs @@ -63,6 +63,13 @@ pub enum Bundle { /// host-side SafeOutputs MCP server can compute a diff base on /// shallow-default pools. PreparePrBase, + /// SHA-pinned component checkout for custom safe-output jobs (#1473). Runs + /// in the isolated custom safe-output job after the component repository + /// resource is checked out. Fetches the pinned commit over the ADO bearer, + /// checks it out detached, and verifies HEAD equals the pin — failing + /// closed. Needed because an ADO repository-resource `ref` cannot be a + /// commit SHA, so a shallow-default checkout lacks the pinned object. + CheckoutComponent, } /// The auth contract a bundle requires from the step that invokes it. @@ -142,6 +149,7 @@ impl Bundle { Bundle::Conclusion, Bundle::GithubAppToken, Bundle::PreparePrBase, + Bundle::CheckoutComponent, ]; /// The bundle's unpacked on-disk path inside the runtime VM. The Conclusion @@ -168,6 +176,7 @@ impl Bundle { Bundle::Conclusion => paths::CONCLUSION_PATH, Bundle::GithubAppToken => paths::GITHUB_APP_TOKEN_PATH, Bundle::PreparePrBase => paths::PREPARE_PR_BASE_PATH, + Bundle::CheckoutComponent => paths::CHECKOUT_COMPONENT_PATH, } } @@ -186,7 +195,9 @@ impl Bundle { | Bundle::ExecContextSchedule | Bundle::Conclusion // Fetches/deepens the target branch over the ADO bearer (bearerEnv). - | Bundle::PreparePrBase => BundleAuth::Bearer, + | Bundle::PreparePrBase + // Fetches the pinned component commit over the ADO bearer (bearerEnv). + | Bundle::CheckoutComponent => BundleAuth::Bearer, // Pure filesystem / git-without-auth / argv — no bearer. Bundle::Import | Bundle::ExecContextManual diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index e41ee9ab..7fda4b60 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -1702,7 +1702,21 @@ fn build_custom_safe_output_job( fetch_tags: Some(false), persist_credentials: None, })); - steps.push(Step::Bash(verify_custom_component_checkout_step(component))); + // Install Node + download the ado-script bundle, then fetch/verify the + // pinned component commit via the checkout-component bundle. An ADO + // repository-resource `ref` cannot be a commit SHA, so the shallow + // resource checkout above lacks the pinned object; the bundle obtains it + // (direct by-SHA fetch → progressive deepening) and verifies a detached + // checkout of it, failing closed on any mismatch. + steps.extend( + super::extensions::ado_script::install_and_download_steps_typed( + front_matter.supply_chain.as_ref(), + ), + ); + steps.push(super::extensions::ado_script::checkout_component_step_typed( + &component.checkout_dir, + &component.sha, + )); } steps.push(Step::Download(DownloadStep { source: "current".to_string(), @@ -1811,22 +1825,6 @@ fn custom_job_condition(def: &CustomSafeOutputJobDef) -> Result { Ok(Condition::And(parts)) } -fn verify_custom_component_checkout_step(component: &CustomComponentRuntime) -> BashStep { - let script = format!( - "set -euo pipefail\n\ - git -C {dir} checkout --detach {sha}\n\ - ACTUAL_SHA=$(git -C {dir} rev-parse HEAD)\n\ - if [ \"$ACTUAL_SHA\" != {sha} ]; then\n \ - echo \"##vso[task.complete result=Failed]custom component checkout resolved $ACTUAL_SHA, expected {sha}\"\n \ - exit 1\n\ - fi\n\ - echo \"Verified custom component checkout at $ACTUAL_SHA\"\n", - dir = shell_quote(&component.checkout_dir), - sha = shell_quote(&component.sha), - ); - bash("Verify custom component checkout", script) -} - fn prepare_custom_executor_binary_step() -> BashStep { bash( "Prepare custom safe-output executor", @@ -4491,6 +4489,20 @@ safe-outputs: }) if alias.starts_with("import_octo_tools_") ) })); + // The pinned SHA is fetched + verified via the checkout-component + // ado-script bundle (not a raw `git checkout` that would fail on a + // shallow pool), with the ADO bearer projected for the fetch. + assert!(custom.steps.iter().any(|step| { + matches!(step, Step::Bash(s) + if s.script.contains("checkout-component.js") + && s.script.contains("--sha '0123456789012345678901234567890123456789'") + && s.env.get("SYSTEM_ACCESSTOKEN").is_some()) + })); + // The old raw-git verify approach must be gone. + assert!(!custom.steps.iter().any(|step| { + matches!(step, Step::Bash(s) if s.script.contains("checkout --detach") + && !s.script.contains("checkout-component.js")) + })); assert!(custom.steps.iter().any(|step| { matches!(step, Step::Bash(s) if s.script.contains("\"notify-team\"") && s.script.contains("\"entrypoint\": \"node notify.js\"") && s.script.contains("\"max\": 2")) })); diff --git a/src/compile/extensions/ado_script.rs b/src/compile/extensions/ado_script.rs index d43b1d5e..6235f072 100644 --- a/src/compile/extensions/ado_script.rs +++ b/src/compile/extensions/ado_script.rs @@ -107,6 +107,12 @@ pub(crate) const GITHUB_APP_TOKEN_PATH: &str = /// shallow-default agent pools. pub(crate) const PREPARE_PR_BASE_PATH: &str = "/tmp/ado-aw-scripts/ado-script/prepare-pr-base.js"; +/// Path to the checkout-component bundle inside the unpacked `ado-script.zip`. +/// Runs in the isolated custom safe-output job (#1473) after the component +/// repository resource is checked out, to fetch the pinned commit and verify a +/// detached checkout of it (fail-closed) on shallow-default agent pools. +pub(crate) const CHECKOUT_COMPONENT_PATH: &str = + "/tmp/ado-aw-scripts/ado-script/checkout-component.js"; const RELEASE_BASE_URL: &str = "https://github.com/githubnext/ado-aw/releases/download"; /// Single always-on extension that owns all `ado-script` bundle wiring. @@ -639,9 +645,34 @@ pub fn prepare_pr_base_step_typed(repos: &[(String, String)]) -> Step { Step::Bash(step) } -/// The GitHub App token **revocation** step (issue #1316). Runs after the -/// Copilot invocation in the Agent and Detection jobs (unless -/// `skip-token-revocation` is set) to delete the minted installation token +/// The SHA-pinned component checkout step (#1473). Runs in the isolated custom +/// safe-output job after the component repository resource is checked out, +/// invoking the `checkout-component` ado-script bundle to obtain the pinned +/// commit (direct by-SHA fetch, then progressive deepening) and verify a +/// detached checkout of it — failing closed if the exact pin cannot be +/// obtained and confirmed. +/// +/// `dir` is the compiler-generated ADO path macro for the component checkout +/// (`$(Build.SourcesDirectory)/`) — DOUBLE-quoted so ADO +/// substitutes the macro before bash runs (single quotes would trip shellcheck +/// SC2016). `sha` is a validated full 40-char commit SHA literal, single-quoted +/// (shadow-proof). The ADO bearer is projected via `apply_bundle_auth` for the +/// authenticated fetch. The step runs OUTSIDE the AWF sandbox on the build +/// agent's normal network, so it needs no AWF allowlist entry. +pub fn checkout_component_step_typed(dir: &str, sha: &str) -> Step { + let script = format!( + "set -eo pipefail\nnode '{CHECKOUT_COMPONENT_PATH}' --dir \"{dir}\" --sha {sha}\n", + dir = dir, + sha = sh_single_quote(sha), + ); + let step = crate::compile::ado_bundle::apply_bundle_auth( + BashStep::new("Checkout pinned custom component", script) + .with_condition(Condition::Succeeded), + crate::compile::ado_bundle::Bundle::CheckoutComponent, + crate::compile::ado_bundle::TokenSource::SystemAccessToken, + ); + Step::Bash(step) +} /// (`DELETE /installation/token`) so it does not remain valid for its full /// ~1h lifetime — matching `actions/create-github-app-token`'s default. /// diff --git a/src/compile/imports/mod.rs b/src/compile/imports/mod.rs index 3a659a45..41779e64 100644 --- a/src/compile/imports/mod.rs +++ b/src/compile/imports/mod.rs @@ -245,6 +245,24 @@ fn read_remote_manifest( let bytes = fs::read(&cache_path) .with_context(|| format!("failed to read cached import {}", cache_path.display()))?; enforce_manifest_size(bytes.len(), &cache_path.display().to_string())?; + // Defense-in-depth: if a digest sidecar exists (written when ado-aw + // populated the cache), verify the cached bytes still hash to it. This + // detects tampering of the committed cache file — which GitHub collapses + // in diffs (`linguist-generated`) — before the manifest is trusted at + // compile time. A missing sidecar (older cache) is tolerated. + let sidecar = digest_sidecar_path(&cache_path); + if let Ok(expected) = fs::read_to_string(&sidecar) { + let actual = sha256_hex(&bytes); + if actual != expected.trim() { + anyhow::bail!( + "cached import {} does not match its recorded digest (expected {}, got {}); \ + the committed cache may have been tampered with — delete it to re-fetch", + cache_path.display(), + expected.trim(), + actual + ); + } + } return Ok(bytes); } @@ -266,9 +284,23 @@ fn read_remote_manifest( ensure_import_gitattributes(repo_root)?; fs::write(&cache_path, &bytes) .with_context(|| format!("failed to write cached import {}", cache_path.display()))?; + // Record the digest sidecar so a later read can detect cache tampering. + fs::write(digest_sidecar_path(&cache_path), sha256_hex(&bytes)).with_context(|| { + format!( + "failed to write import cache digest sidecar for {}", + cache_path.display() + ) + })?; Ok(bytes) } +/// The `.sha256` sidecar path recording a cached manifest's content digest. +fn digest_sidecar_path(cache_path: &Path) -> PathBuf { + let mut os = cache_path.as_os_str().to_os_string(); + os.push(".sha256"); + PathBuf::from(os) +} + fn enforce_manifest_size(size: usize, source: &str) -> Result<()> { if size > MAX_MANIFEST_BYTES { anyhow::bail!( @@ -355,7 +387,22 @@ fn parse_manifest_bytes( let value: serde_yaml::Value = serde_yaml::from_str(&yaml).context("failed to parse import YAML front matter")?; match value { - serde_yaml::Value::Mapping(_) | serde_yaml::Value::Null => value, + serde_yaml::Value::Mapping(ref mapping) => { + // Transitive/nested imports are not yet resolved (only a + // workflow's own top-level `imports:` is). Rather than + // silently ignore a component's own `imports:` — which would + // drop tools/config it depends on with no diagnostic — fail + // loudly until nested resolution lands. + if mapping.contains_key(serde_yaml::Value::String("imports".to_string())) { + anyhow::bail!( + "imported component declares its own `imports:`, but nested \ + (transitive) imports are not yet supported; flatten the \ + component or inline the nested import" + ); + } + value + } + serde_yaml::Value::Null => value, other => { anyhow::bail!( "import YAML front matter must be a mapping/object, got {}", @@ -374,7 +421,7 @@ fn parse_manifest_bytes( Ok((front_matter, body)) } -fn yaml_value_kind(value: &serde_yaml::Value) -> &'static str { +pub(super) fn yaml_value_kind(value: &serde_yaml::Value) -> &'static str { match value { serde_yaml::Value::Null => "null", serde_yaml::Value::Bool(_) => "boolean", @@ -573,6 +620,60 @@ mod tests { assert!(format!("{err:#}").contains("exceeding the 262144 byte limit")); } + #[test] + fn nested_imports_in_component_are_rejected() { + let repo = tempfile::tempdir().unwrap(); + fs::write( + repo.path().join("component.md"), + b"---\nimports:\n - other.md\n---\n# Body\n", + ) + .unwrap(); + + let err = resolve_imports_with_repo_root( + &[import_entry("component.md")], + repo.path(), + repo.path(), + &PanicFetcher, + ) + .unwrap_err(); + assert!( + format!("{err:#}").contains("nested (transitive) imports are not yet supported"), + "got: {err:#}" + ); + } + + #[test] + fn tampered_cache_is_rejected_via_digest_sidecar() { + let repo = tempfile::tempdir().unwrap(); + let entry = import_entry(&format!("acme/shared/components/deploy.md@{SHA}")); + let fetcher = StaticFetcher { + bytes: manifest().to_vec(), + }; + // First resolve populates the cache + digest sidecar. + resolve_imports_with_repo_root(std::slice::from_ref(&entry), repo.path(), repo.path(), &fetcher) + .unwrap(); + + // Tamper with the committed cache file (sidecar still records the + // original digest). + let cache_file = repo + .path() + .join(".ado-aw") + .join("imports") + .join("acme") + .join("shared") + .join(SHA) + .join("components_deploy.md"); + fs::write(&cache_file, b"---\n{}\n---\n# Tampered\nevil\n").unwrap(); + + let err = + resolve_imports_with_repo_root(&[entry], repo.path(), repo.path(), &PanicFetcher) + .unwrap_err(); + assert!( + format!("{err:#}").contains("does not match its recorded digest"), + "got: {err:#}" + ); + } + #[test] fn section_selector_extracts_only_that_section() { let repo = tempfile::tempdir().unwrap(); diff --git a/src/compile/imports/schema.rs b/src/compile/imports/schema.rs index 4edd5f6c..9f02cb3f 100644 --- a/src/compile/imports/schema.rs +++ b/src/compile/imports/schema.rs @@ -379,7 +379,7 @@ fn yaml_mapping<'a>(value: &'a YamlValue, path: &str) -> Result<&'a YamlMapping> YamlValue::Mapping(mapping) => Ok(mapping), _ => anyhow::bail!( "import-schema field `{path}` must be a mapping, got {}", - yaml_value_kind(value) + super::yaml_value_kind(value) ), } } @@ -389,7 +389,7 @@ fn yaml_sequence<'a>(value: &'a YamlValue, path: &str) -> Result<&'a Vec Ok(sequence), _ => anyhow::bail!( "import-schema field `{path}` must be a sequence, got {}", - yaml_value_kind(value) + super::yaml_value_kind(value) ), } } @@ -399,7 +399,7 @@ fn yaml_string<'a>(value: &'a YamlValue, path: &str) -> Result<&'a str> { YamlValue::String(value) => Ok(value), _ => anyhow::bail!( "import-schema field `{path}` must be a string, got {}", - yaml_value_kind(value) + super::yaml_value_kind(value) ), } } @@ -462,18 +462,6 @@ fn dotted_path(prefix: &str, key: &str) -> String { } } -fn yaml_value_kind(value: &YamlValue) -> &'static str { - match value { - YamlValue::Null => "null", - YamlValue::Bool(_) => "boolean", - YamlValue::Number(_) => "number", - YamlValue::String(_) => "string", - YamlValue::Sequence(_) => "sequence/array", - YamlValue::Mapping(_) => "mapping/object", - YamlValue::Tagged(_) => "tagged value", - } -} - fn json_value_kind(value: &JsonValue) -> &'static str { match value { JsonValue::Null => "null", diff --git a/src/execute.rs b/src/execute.rs index 5350d956..0c288c5e 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -9,11 +9,11 @@ use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; use std::collections::{HashMap, HashSet}; -use std::io::Write as _; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Stdio; use tokio::fs::OpenOptions; use tokio::io::AsyncWriteExt; +use tokio::process::Command; use crate::ndjson::{self, EXECUTED_NDJSON_FILENAME, SAFE_OUTPUT_FILENAME}; use crate::safe_outputs::{ @@ -647,20 +647,23 @@ async fn run_custom_entrypoint( } }; - if let Some(stdin) = child.stdin.as_mut() - && let Err(err) = stdin.write_all(proposal_json.as_bytes()) - { - return CustomToolOutcome { - result: ExecutionResult::failure(format!( - "Failed to write proposal '{}' to custom tool stdin: {}", - sanitize(proposal_id), - sanitize(&err.to_string()) - )), - record_status: "failed", - }; + // Write the proposal to the child's stdin CONCURRENTLY with draining its + // stdout/stderr. Writing the whole payload before reading any output would + // deadlock if the child emits more than a pipe buffer's worth before + // consuming stdin. The payload is ALSO available to the child via the + // `AW_PROPOSAL` env var; dropping the stdin handle when the write finishes + // signals EOF to a dispatcher that reads stdin. A stdin write error is + // non-fatal here (broken pipe if the child ignored stdin) — the child's own + // exit status is authoritative and handled below. + let stdin_payload = proposal_json.clone(); + if let Some(mut stdin) = child.stdin.take() { + tokio::spawn(async move { + let _ = stdin.write_all(stdin_payload.as_bytes()).await; + // `stdin` is dropped here, closing the pipe (EOF). + }); } - let output = match child.wait_with_output() { + let output = match child.wait_with_output().await { Ok(output) => output, Err(err) => { return CustomToolOutcome { @@ -834,10 +837,10 @@ fn now_timestamp() -> String { Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) } -fn custom_record_status(result: &ExecutionResult) -> &'static str { +fn custom_record_status(result: &ExecutionResult, staged: bool) -> &'static str { if result.is_budget_exhausted() { "budget_exhausted" - } else if result.message.starts_with("Staged custom tool") { + } else if staged && result.success { "staged" } else { execution_record_status(result) @@ -889,8 +892,8 @@ async fn append_custom_execution_record_for_result_with_times( record_status_override: Option<&str>, ) { let status = record_status_override - .unwrap_or_else(|| custom_record_status(result)) - .to_string(); + .map(str::to_string) + .unwrap_or_else(|| custom_record_status(result, attempt.staged).to_string()); let data = result.data.clone().map(sanitize_json_value); let message = sanitize(&result.message); let record = CustomExecutionRecord { From c94f02ee47f55005b3668368e67a76275109e799 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 14 Jul 2026 23:40:17 +0100 Subject: [PATCH 04/12] feat(compile): make Azure Repos the primary import manifest fetcher Cross-repository `imports:` previously fetched manifests only from GitHub (`gh api`), while the runtime checkout already treated endpoint-less imports as same-org Azure Repos. Endpoint-less (Azure-Repos-intended) imports thus could not resolve their manifest at compile time and silently queried GitHub (source confusion). - Type the import `endpoint`: absent => same-org Azure Repos (primary); `github` (bare-string shorthand), `ghe` (+host), and `azure-repos` (+org, cross-org) object forms with per-variant validation. - Make the `ManifestFetcher` trait async (`#[async_trait]`); `GhCliFetcher` now uses `tokio::process` and sets `GH_HOST` for GHE. - Add `AdoRepoFetcher` (ADO Git Items API, SHA-pinned) and a `RoutingFetcher` that dispatches by endpoint type. Routing is fail-closed: an Azure-Repos import never falls back to GitHub and vice-versa. - Non-interactive ADO auth (SYSTEM_ACCESSTOKEN -> AZURE_DEVOPS_EXT_PAT -> az CLI) + `fetch_git_item` helper with ADO sign-in detection in src/ado. - Map cross-org Azure Repos -> `type: git` + connection, GHE -> `githubenterprise` in repository-resource synthesis. - Tests: endpoint parsing/validation, routing regression guard, fail-closed guards, cross-org/GHE alias mapping. Update docs/imports.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f --- docs/imports.md | 73 ++++- src/ado/mod.rs | 118 ++++++++ src/compile/imports/alias.rs | 151 +++++++++- src/compile/imports/integration_tests.rs | 46 +-- src/compile/imports/merge.rs | 4 +- src/compile/imports/mod.rs | 340 ++++++++++++++++++++--- src/compile/mod.rs | 42 ++- src/compile/types.rs | 263 +++++++++++++++++- 8 files changed, 951 insertions(+), 86 deletions(-) diff --git a/docs/imports.md b/docs/imports.md index 9308515b..3d9d3828 100644 --- a/docs/imports.md +++ b/docs/imports.md @@ -21,8 +21,11 @@ with `uses`, optional `with`, and optional `endpoint`: ```yaml imports: + # Same-org Azure Repos (the primary, default source — no endpoint): + - myproject/shared-agents/components/notify.md@0123456789abcdef0123456789abcdef01234567 + # Local import: - ./components/local-guidance.md - - octo/shared-agents/components/notify.md@0123456789abcdef0123456789abcdef01234567 + # GitHub.com, via an ADO service connection (bare-string endpoint shorthand): - uses: octo/shared-agents/components/deploy.md@89abcdef0123456789abcdef0123456789abcdef endpoint: github-shared-components with: @@ -42,11 +45,47 @@ imports: The optional marker is trailing, so a sectioned optional import looks like `owner/repo/component.md@0123...cdef#Usage?`. -`endpoint:` names the Azure DevOps service connection used by the generated -runtime repository resource for GitHub/GitHub Enterprise component sources. It -is not used for the compile-time manifest fetch. Azure Repos (`type: git`) -checkouts do not need an endpoint; GitHub, GitHub Enterprise, and Bitbucket -repository resources do. See [Repository resource endpoints](network.md#repository-resource-endpoints). +For a cross-repository spec `owner/repo/path@`, `owner` maps to the Azure +DevOps **project** (or GitHub owner) and `repo` to the repository name. + +### `endpoint:` — source type and service connection + +`endpoint:` selects the **source type** of a cross-repository import and names +the Azure DevOps service connection the generated runtime repository resource +authenticates with. It drives **both** the compile-time manifest fetch **and** +the runtime checkout, so the two can never disagree. + +- **Absent** → **same-organization Azure Repos** (the primary, default case for + this ADO-native compiler). Fetched at compile time via the ADO Git Items API + and checked out at runtime with `System.AccessToken` (`type: git`, no + endpoint). +- **Bare string** (`endpoint: my-connection`) → shorthand for a **GitHub.com** + service connection. +- **Object form** with an explicit `type`: + + | `type` | Extra fields | Source | Runtime `type` | + |--------|--------------|--------|----------------| + | `github` (default) | — | GitHub.com | `github` | + | `ghe` | `host:` (API host, e.g. `api.acme.ghe.com`) | GitHub Enterprise | `githubenterprise` | + | `azure-repos` | `org:` (target collection URL, e.g. `https://dev.azure.com/otherorg`) | **Cross-organization** Azure Repos | `git` | + +```yaml +imports: + # Cross-org Azure Repos: + - uses: otherproject/otherrepo/component.md@0123456789abcdef0123456789abcdef01234567 + endpoint: + name: other-org-repos-connection + type: azure-repos + org: https://dev.azure.com/otherorg + # GitHub Enterprise: + - uses: octo/components/deploy.md@89abcdef0123456789abcdef0123456789abcdef + endpoint: + name: ghe-connection + type: ghe + host: api.acme.ghe.com +``` + +See [Repository resource endpoints](network.md#repository-resource-endpoints). ## Cross-repository resolution and cache @@ -67,10 +106,28 @@ not vendored into `.ado-aw/imports/`; script-bearing custom safe-output components are checked out in their dedicated executor job and verified at the pinned SHA before their code runs. +### Compile-time manifest fetch + +The manifest fetcher is selected from the import's `endpoint` type so that the +compile-time fetch always matches the runtime checkout source: + +- **Azure Repos** (endpoint-less same-org, or `type: azure-repos` cross-org) — + fetched via the ADO Git Items API. Credentials are resolved + **non-interactively** in this precedence: `SYSTEM_ACCESSTOKEN` → + `AZURE_DEVOPS_EXT_PAT` → `az account get-access-token`. The consumer + organization is taken from `AZURE_DEVOPS_ORG_URL` / `SYSTEM_COLLECTIONURI` or + the repo's Azure Repos git remote; cross-org imports use the endpoint's + `org:`. +- **GitHub / GitHub Enterprise** (`type: github` / `type: ghe`) — fetched via + `gh api` using the compiler host's GitHub auth (`GH_HOST` targets the GHE + instance). + +Routing is **fail-closed**: an endpoint-less (Azure-Repos-intended) import never +silently falls back to GitHub, and a GitHub-typed import is never served by the +Azure Repos fetcher. + Current MVP notes: -- The remote manifest fetcher uses the GitHub Contents API via `gh api` with the - compiler host's GitHub auth. Azure Repos manifest fetching is a follow-up. - Nested/transitive import resolution is not expanded yet; the current resolver processes the workflow's top-level `imports:` list. - A workflow may declare at most 20 imports, and each resolved manifest is diff --git a/src/ado/mod.rs b/src/ado/mod.rs index 0d118768..e7ddfdbd 100644 --- a/src/ado/mod.rs +++ b/src/ado/mod.rs @@ -995,6 +995,41 @@ pub async fn resolve_auth(pat: Option<&str>) -> Result { } } +/// Resolve ADO authentication for **non-interactive** (compile-time / CI) +/// contexts, without ever prompting. +/// +/// Precedence: +/// 1. `SYSTEM_ACCESSTOKEN` — the ADO pipeline job token (bearer). +/// 2. `AZURE_DEVOPS_EXT_PAT` — a personal access token (HTTP Basic). +/// 3. `az account get-access-token` — an Azure CLI AAD token (bearer). +/// +/// Unlike [`resolve_auth`], this never falls back to an interactive prompt, so +/// it is safe to call from `ado-aw compile` and other unattended entry points. +pub async fn resolve_auth_non_interactive() -> Result { + if let Ok(token) = std::env::var("SYSTEM_ACCESSTOKEN") + && !token.trim().is_empty() + { + debug!("Using SYSTEM_ACCESSTOKEN for Azure DevOps auth"); + return Ok(AdoAuth::Bearer(token)); + } + if let Ok(pat) = std::env::var("AZURE_DEVOPS_EXT_PAT") + && !pat.trim().is_empty() + { + debug!("Using AZURE_DEVOPS_EXT_PAT for Azure DevOps auth"); + return Ok(AdoAuth::Pat(pat)); + } + match try_azure_cli_token().await { + Ok(token) => { + debug!("Using Azure CLI token for Azure DevOps auth"); + Ok(AdoAuth::Bearer(token)) + } + Err(e) => Err(anyhow::anyhow!( + "no non-interactive Azure DevOps credentials available: set \ + SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT, or run `az login` ({e:#})" + )), + } +} + /// Normalize a `--org` value to a full ADO organization URL. /// /// Users commonly pass just the org name (e.g. `myorg`) instead of the full @@ -1282,6 +1317,89 @@ pub async fn get_repository_id( .with_context(|| format!("Repository '{}' response has no 'id' field", repo_name)) } +/// Fetch the raw bytes of a single SHA-pinned file from an Azure Repos git +/// repository via the ADO Git Items API. +/// +/// Calls +/// `GET {org_url}/{project}/_apis/git/repositories/{repo}/items?path={item_path} +/// &versionDescriptor.version={commit_sha}&versionDescriptor.versionType=commit +/// &includeContent=true&api-version=7.1` +/// and returns the file `content` as UTF-8 bytes. +/// +/// `org_url` is a full organization collection URL (e.g. +/// `https://dev.azure.com/myorg`); for cross-organization imports pass the +/// target org's URL. Detects the ADO AAD sign-in HTML response (see +/// [`looks_like_ado_signin`]) before attempting to parse JSON so an auth +/// failure surfaces an actionable error rather than a JSON-parse error. +pub async fn fetch_git_item( + client: &reqwest::Client, + org_url: &str, + project: &str, + repo: &str, + item_path: &str, + commit_sha: &str, + auth: &AdoAuth, +) -> Result> { + let url = format!( + "{}/{}/_apis/git/repositories/{}/items?path={}\ + &versionDescriptor.version={}&versionDescriptor.versionType=commit\ + &includeContent=true&api-version=7.1", + org_url.trim_end_matches('/'), + percent_encoding::utf8_percent_encode(project, PATH_SEGMENT), + percent_encoding::utf8_percent_encode(repo, PATH_SEGMENT), + percent_encoding::utf8_percent_encode(item_path, PATH_SEGMENT), + percent_encoding::utf8_percent_encode(commit_sha, PATH_SEGMENT), + ); + + debug!("Fetching Azure Repos item: {}", url); + + let resp = auth + .apply(client.get(&url)) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await + .with_context(|| format!("Failed to request Azure Repos item '{}'", item_path))?; + + let status = resp.status(); + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let body = resp + .text() + .await + .with_context(|| format!("Failed to read Azure Repos item response for '{}'", item_path))?; + + if looks_like_ado_signin(status, content_type.as_deref(), &body) { + return Err(ado_signin_error(auth)); + } + if !status.is_success() { + anyhow::bail!( + "ADO Git Items API returned {} for '{}': {}", + status, + item_path, + body.trim() + ); + } + + #[derive(Deserialize)] + struct GitItem { + content: Option, + } + + let item: GitItem = serde_json::from_str(&body).with_context(|| { + format!("Failed to parse Azure Repos item response for '{}'", item_path) + })?; + let content = item.content.with_context(|| { + format!( + "Azure Repos item '{}' response contained no `content` (is it a directory?)", + item_path + ) + })?; + Ok(content.into_bytes()) +} + /// Resolve a GitHub service-connection identifier to its GUID. /// /// Accepts either a raw UUID (returned unchanged — no API call) or a diff --git a/src/compile/imports/alias.rs b/src/compile/imports/alias.rs index fc45ec6e..f83533f6 100644 --- a/src/compile/imports/alias.rs +++ b/src/compile/imports/alias.rs @@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet}; use anyhow::Result; use super::ResolvedImport; -use crate::compile::types::{CompileTarget, ImportSource, Repository}; +use crate::compile::types::{CompileTarget, ImportEndpoint, ImportSource, Repository}; use crate::hash::sha256_hex; const REPOSITORY_RESOURCE_REF: &str = "refs/heads/main"; @@ -31,7 +31,7 @@ const HASH_SUFFIX_LEN: usize = 12; struct RepoKey { owner: String, repo: String, - endpoint: Option, + endpoint: Option, } /// Return the stable compiler-generated repository-resource alias for a remote @@ -93,35 +93,60 @@ pub fn synthesize_repo_aliases(imports: &[ResolvedImport]) -> Result ("git", None), + Some(ImportEndpoint::AzureReposCrossOrg { name, .. }) => { + ("git", Some(name.clone())) + } + Some(ImportEndpoint::GitHub { name }) => ("github", Some(name.clone())), + Some(ImportEndpoint::GitHubEnterprise { name, .. }) => { + ("githubenterprise", Some(name.clone())) + } + }; + Repository { repository: alias, - // MVP inference: imports with an explicit endpoint are backed - // by a GitHub/GHE service connection; endpoint-less imports are - // same-org Azure Repos checkouts using System.AccessToken. - repo_type: if key.endpoint.is_some() { - "github".to_string() - } else { - "git".to_string() - }, + repo_type: repo_type.to_string(), name: format!("{}/{}", key.owner, key.repo), // NOTE: ADO repository-resource `ref` does not accept commit // SHAs. This branch ref is for resource authorization only; // the executor checkout must pin the exact import SHA at // runtime with `git checkout `. repo_ref: REPOSITORY_RESOURCE_REF.to_string(), - endpoint: key.endpoint, + endpoint: endpoint_name, } }) .collect()) } +/// Stable string discriminator for the alias hash suffix so that two imports of +/// the same `owner/repo` through different endpoints still receive distinct +/// aliases. +fn endpoint_hash_discriminator(endpoint: Option<&ImportEndpoint>) -> String { + match endpoint { + None => String::new(), + Some(ImportEndpoint::GitHub { name }) => format!("github:{name}"), + Some(ImportEndpoint::GitHubEnterprise { name, host }) => { + format!("ghe:{name}:{}", host.as_str()) + } + Some(ImportEndpoint::AzureReposCrossOrg { name, org }) => { + format!("azure-repos:{name}:{org}") + } + } +} + fn sanitize_identifier_part(value: &str) -> String { let sanitized: String = value .chars() @@ -193,11 +218,14 @@ mod tests { sha: &str, endpoint: Option<&str>, ) -> ResolvedImport { + let endpoint = endpoint.map(|name| ImportEndpoint::GitHub { + name: name.to_string(), + }); ResolvedImport { entry: ImportEntry { uses: format!("{owner}/{repo}/{path}@{sha}"), with: serde_json::Map::new(), - endpoint: endpoint.map(str::to_string), + endpoint: endpoint.clone(), }, source: ImportSource::Remote(ParsedImportSpec { owner: owner.to_string(), @@ -206,6 +234,7 @@ mod tests { sha: CommitSha::parse(sha).expect("test sha should be valid"), section: None, optional: false, + endpoint, }), front_matter: serde_yaml::Value::Null, body: String::new(), @@ -289,6 +318,102 @@ mod tests { assert_eq!(repos[0].name, "ado/repo"); } + fn remote_import_with_endpoint( + owner: &str, + repo: &str, + sha: &str, + endpoint: ImportEndpoint, + ) -> ResolvedImport { + let endpoint = Some(endpoint); + ResolvedImport { + entry: ImportEntry { + uses: format!("{owner}/{repo}/component.md@{sha}"), + with: serde_json::Map::new(), + endpoint: endpoint.clone(), + }, + source: ImportSource::Remote(ParsedImportSpec { + owner: owner.to_string(), + repo: repo.to_string(), + path: "component.md".to_string(), + sha: CommitSha::parse(sha).expect("valid sha"), + section: None, + optional: false, + endpoint, + }), + front_matter: serde_yaml::Value::Null, + body: String::new(), + provenance: ImportProvenance { + source: format!("{owner}/{repo}/component.md"), + sha: Some(sha.to_string()), + manifest_digest: "digest".to_string(), + }, + } + } + + #[test] + fn cross_org_azure_repos_endpoint_synthesizes_git_resource_with_connection() { + let imports = vec![remote_import_with_endpoint( + "proj", + "repo", + "5123456789abcdef0123456789abcdef01234567", + ImportEndpoint::AzureReposCrossOrg { + name: "other-org-conn".to_string(), + org: "https://dev.azure.com/other".to_string(), + }, + )]; + + let repos = synthesize_repo_aliases(&imports).expect("synthesis should succeed"); + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].repo_type, "git"); + assert_eq!(repos[0].endpoint.as_deref(), Some("other-org-conn")); + assert_eq!(repos[0].name, "proj/repo"); + } + + #[test] + fn ghe_endpoint_synthesizes_githubenterprise_resource() { + let imports = vec![remote_import_with_endpoint( + "octo", + "repo", + "6123456789abcdef0123456789abcdef01234567", + ImportEndpoint::GitHubEnterprise { + name: "ghe-conn".to_string(), + host: crate::secure::HostName::parse("api.acme.ghe.com").unwrap(), + }, + )]; + + let repos = synthesize_repo_aliases(&imports).expect("synthesis should succeed"); + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].repo_type, "githubenterprise"); + assert_eq!(repos[0].endpoint.as_deref(), Some("ghe-conn")); + } + + #[test] + fn same_repo_different_endpoint_types_get_distinct_resources() { + let imports = vec![ + remote_import_with_endpoint( + "o", + "r", + "7123456789abcdef0123456789abcdef01234567", + ImportEndpoint::GitHub { + name: "gh".to_string(), + }, + ), + remote_import_with_endpoint( + "o", + "r", + "8123456789abcdef0123456789abcdef01234567", + ImportEndpoint::AzureReposCrossOrg { + name: "az".to_string(), + org: "https://dev.azure.com/other".to_string(), + }, + ), + ]; + + let repos = synthesize_repo_aliases(&imports).expect("synthesis should succeed"); + assert_eq!(repos.len(), 2); + assert_ne!(repos[0].repository, repos[1].repository); + } + #[test] fn same_repo_at_different_paths_and_shas_is_deduplicated() { let imports = vec![ diff --git a/src/compile/imports/integration_tests.rs b/src/compile/imports/integration_tests.rs index 6f50100c..437c97dd 100644 --- a/src/compile/imports/integration_tests.rs +++ b/src/compile/imports/integration_tests.rs @@ -7,15 +7,18 @@ use serde_yaml::{Mapping, Value}; use super::alias::{import_resource_parent_diagnostic, synthesize_repo_aliases}; use super::merge::merge_resolved; use super::{ImportProvenance, ManifestFetcher, ResolvedImport, resolve_imports}; -use crate::compile::types::{CompileTarget, ImportEntry, ImportSource, ParsedImportSpec}; +use crate::compile::types::{ + CompileTarget, ImportEndpoint, ImportEntry, ImportSource, ParsedImportSpec, +}; use crate::secure::CommitSha; const SHA: &str = "0123456789abcdef0123456789abcdef01234567"; struct PanicFetcher; +#[async_trait::async_trait] impl ManifestFetcher for PanicFetcher { - fn fetch(&self, _spec: &ParsedImportSpec) -> Result> { + async fn fetch(&self, _spec: &ParsedImportSpec) -> Result> { panic!("integration tests must not fetch remote imports") } } @@ -97,12 +100,14 @@ fn write_component(dir: &Path, name: &str, content: &str) { fs::write(path, content).expect("write component"); } -fn resolve_local(entries: &[ImportEntry], base_dir: &Path) -> Vec { - resolve_imports(entries, base_dir, &PanicFetcher).expect("resolve local imports") +async fn resolve_local(entries: &[ImportEntry], base_dir: &Path) -> Vec { + resolve_imports(entries, base_dir, &PanicFetcher) + .await + .expect("resolve local imports") } -#[test] -fn imports_integration_local_resolve_then_merge_consumer_wins_and_unions() { +#[tokio::test] +async fn imports_integration_local_resolve_then_merge_consumer_wins_and_unions() { let repo = temp_repo(); let workflow_dir = repo.path().join("workflows"); fs::create_dir_all(&workflow_dir).expect("create workflow dir"); @@ -141,7 +146,7 @@ Consumer guidance. .expect("write workflow"); let (mut consumer_fm, consumer_body, entries) = parse_workflow(&workflow_path); - let resolved = resolve_local(&entries, &workflow_dir); + let resolved = resolve_local(&entries, &workflow_dir).await; let merged_body = merge_resolved(&mut consumer_fm, &consumer_body, &resolved).expect("merge imports"); @@ -161,8 +166,8 @@ Consumer guidance. assert_eq!(merged_body, "Imported guidance.\n\nConsumer guidance."); } -#[test] -fn imports_integration_schema_inputs_are_substituted_before_merge() { +#[tokio::test] +async fn imports_integration_schema_inputs_are_substituted_before_merge() { let repo = temp_repo(); let workflow_dir = repo.path().join("workflows"); fs::create_dir_all(&workflow_dir).expect("create workflow dir"); @@ -200,7 +205,7 @@ Consumer body. .expect("write workflow"); let (mut consumer_fm, consumer_body, entries) = parse_workflow(&workflow_path); - let resolved = resolve_local(&entries, &workflow_dir); + let resolved = resolve_local(&entries, &workflow_dir).await; let merged_body = merge_resolved(&mut consumer_fm, &consumer_body, &resolved).expect("merge imports"); @@ -239,8 +244,8 @@ fn imports_integration_remote_specs_must_be_sha_pinned() { ); } -#[test] -fn imports_integration_merge_conflicts_and_safe_output_configuration() { +#[tokio::test] +async fn imports_integration_merge_conflicts_and_safe_output_configuration() { let repo = temp_repo(); let workflow_dir = repo.path().join("workflows"); fs::create_dir_all(&workflow_dir).expect("create workflow dir"); @@ -257,7 +262,8 @@ fn imports_integration_merge_conflicts_and_safe_output_configuration() { let duplicate_tools = resolve_local( &[import_entry("tool-one.md"), import_entry("tool-two.md")], &workflow_dir, - ); + ) + .await; let err = merge_resolved(&mut ymap("name: consumer"), "", &duplicate_tools) .expect_err("duplicate imported tools should fail"); assert!(err.to_string().contains("tools.edit"), "{err}"); @@ -267,7 +273,7 @@ fn imports_integration_merge_conflicts_and_safe_output_configuration() { "notify.md", "---\nsafe-outputs:\n notify:\n run: notify.js\n---\nnotify\n", ); - let notify = resolve_local(&[import_entry("notify.md")], &workflow_dir); + let notify = resolve_local(&[import_entry("notify.md")], &workflow_dir).await; let err = merge_resolved( &mut ymap("safe-outputs:\n notify:\n run: consumer.js"), "", @@ -291,14 +297,15 @@ fn imports_integration_merge_conflicts_and_safe_output_configuration() { assert_eq!(map_get(notify_cfg, "require-approval"), &Value::Bool(true)); } -#[test] -fn imports_integration_resolve_enforces_import_count_limit() { +#[tokio::test] +async fn imports_integration_resolve_enforces_import_count_limit() { let repo = temp_repo(); let entries: Vec = (0..21) .map(|idx| import_entry(&format!("missing-{idx}.md?"))) .collect(); let err = resolve_imports(&entries, repo.path(), &PanicFetcher) + .await .expect_err("more than 20 imports should fail before resolution"); assert!( @@ -314,7 +321,9 @@ fn imports_integration_remote_alias_synthesis_and_template_diagnostic() { entry: ImportEntry { uses: format!("octo/components/deploy.md@{SHA}"), with: serde_json::Map::new(), - endpoint: Some("github-service-connection".to_string()), + endpoint: Some(ImportEndpoint::GitHub { + name: "github-service-connection".to_string(), + }), }, source: ImportSource::Remote(ParsedImportSpec { owner: "octo".to_string(), @@ -323,6 +332,9 @@ fn imports_integration_remote_alias_synthesis_and_template_diagnostic() { sha: CommitSha::parse(SHA).expect("valid sha"), section: None, optional: false, + endpoint: Some(ImportEndpoint::GitHub { + name: "github-service-connection".to_string(), + }), }), front_matter: Value::Null, body: String::new(), diff --git a/src/compile/imports/merge.rs b/src/compile/imports/merge.rs index 904f16c0..783e46dd 100644 --- a/src/compile/imports/merge.rs +++ b/src/compile/imports/merge.rs @@ -45,7 +45,7 @@ const EXECUTOR_KEYS: &[&str] = &["steps", "env", "inputs", "run", "entrypoint"]; /// /// Returns the merged markdown body. `consumer_fm` is mutated in place and its /// `imports:` key is removed (imports are consumed by this pass). -pub fn merge_imports( +pub async fn merge_imports( consumer_fm: &mut Mapping, consumer_body: &str, entries: &[ImportEntry], @@ -53,7 +53,7 @@ pub fn merge_imports( repo_root: &Path, fetcher: &dyn ManifestFetcher, ) -> Result { - let resolved = resolve_imports_with_repo_root(entries, base_dir, repo_root, fetcher)?; + let resolved = resolve_imports_with_repo_root(entries, base_dir, repo_root, fetcher).await?; merge_resolved(consumer_fm, consumer_body, &resolved) } diff --git a/src/compile/imports/mod.rs b/src/compile/imports/mod.rs index 41779e64..b2223b47 100644 --- a/src/compile/imports/mod.rs +++ b/src/compile/imports/mod.rs @@ -13,13 +13,13 @@ pub mod schema; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use anyhow::{Context, Result}; +use async_trait::async_trait; use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde::Deserialize; -use crate::compile::types::{ImportEntry, ImportSource, ParsedImportSpec}; +use crate::compile::types::{ImportEndpoint, ImportEntry, ImportSource, ParsedImportSpec}; use crate::hash::sha256_hex; const MAX_IMPORTS_PER_WORKFLOW: usize = 20; @@ -30,16 +30,21 @@ const IMPORT_GITATTRIBUTES: &str = "# Mark all cached import files as generated\ * merge=ours\n"; /// Fetches a single SHA-pinned component manifest. +#[async_trait] pub trait ManifestFetcher { - fn fetch(&self, spec: &ParsedImportSpec) -> Result>; + async fn fetch(&self, spec: &ParsedImportSpec) -> Result>; } /// GitHub Contents API-backed manifest fetcher using the author's `gh` auth. +/// +/// Handles both GitHub.com ([`ImportEndpoint::GitHub`]) and GitHub Enterprise +/// ([`ImportEndpoint::GitHubEnterprise`]) sources; for GHE the target API host +/// is passed to `gh` via the `GH_HOST` environment variable. pub struct GhCliFetcher; +#[async_trait] impl ManifestFetcher for GhCliFetcher { - fn fetch(&self, spec: &ParsedImportSpec) -> Result> { - // TODO: Azure Repos manifest fetch (follow-up). + async fn fetch(&self, spec: &ParsedImportSpec) -> Result> { let route = format!( "repos/{}/{}/contents/{}?ref={}", spec.owner, @@ -47,9 +52,18 @@ impl ManifestFetcher for GhCliFetcher { spec.path, spec.sha.as_str() ); - let output = Command::new("gh") - .args(["api", &route]) + + let mut command = tokio::process::Command::new("gh"); + command.args(["api", &route]); + // GitHub Enterprise: target the configured API host. `GH_HOST` makes + // `gh api` resolve the relative route against that instance. + if let Some(ImportEndpoint::GitHubEnterprise { host, .. }) = &spec.endpoint { + command.env("GH_HOST", host.as_str()); + } + + let output = command .output() + .await .with_context(|| format!("failed to run `gh api {route}` for import manifest"))?; if !output.status.success() { @@ -92,6 +106,157 @@ impl ManifestFetcher for GhCliFetcher { } } +/// Azure Repos-backed manifest fetcher — the **primary** compile-time source. +/// +/// Fetches a SHA-pinned manifest from the ADO Git Items API. Endpoint-less +/// imports resolve against the consumer's own organization (`context_org_url`); +/// [`ImportEndpoint::AzureReposCrossOrg`] imports resolve against the +/// organization named in the endpoint. The import spec's `owner` maps to the +/// ADO **project** and `repo` to the repository name. +/// +/// `context_org_url` and `auth` are resolved once, best-effort, at construction +/// time (only when the workflow actually declares an Azure Repos import). A +/// failure to resolve either is deferred and surfaced **fail-closed** when an +/// Azure Repos import is actually fetched — an Azure-Repos-typed import never +/// silently falls back to GitHub. +pub struct AdoRepoFetcher { + client: reqwest::Client, + /// Consumer organization collection URL for same-org imports, or the reason + /// it could not be resolved. + context_org_url: std::result::Result, + /// Non-interactive ADO auth, or the reason it could not be resolved. + auth: std::result::Result, +} + +impl AdoRepoFetcher { + /// Construct a fetcher from a best-effort org URL and auth resolution. + pub fn new( + context_org_url: std::result::Result, + auth: std::result::Result, + ) -> Self { + Self { + client: reqwest::Client::new(), + context_org_url, + auth, + } + } +} + +#[async_trait] +impl ManifestFetcher for AdoRepoFetcher { + async fn fetch(&self, spec: &ParsedImportSpec) -> Result> { + let org_url = match &spec.endpoint { + None => self.context_org_url.as_deref().map_err(|reason| { + anyhow::anyhow!( + "cannot fetch same-org Azure Repos import `{}/{}/{}`: {}. \ + Set AZURE_DEVOPS_ORG_URL / SYSTEM_COLLECTIONURI or run from an \ + Azure Repos checkout; to import from GitHub, add an `endpoint:`.", + spec.owner, + spec.repo, + spec.path, + reason + ) + })?, + Some(ImportEndpoint::AzureReposCrossOrg { org, .. }) => org.as_str(), + Some(other) => { + // Fail-closed: a GitHub/GHE-typed import must never reach the + // Azure Repos fetcher. + anyhow::bail!( + "internal routing error: Azure Repos fetcher received a {:?} import", + other + ); + } + }; + + let auth = self.auth.as_ref().map_err(|reason| { + anyhow::anyhow!( + "cannot authenticate to Azure Repos for import `{}/{}/{}`: {}", + spec.owner, + spec.repo, + spec.path, + reason + ) + })?; + + crate::ado::fetch_git_item( + &self.client, + org_url, + &spec.owner, + &spec.repo, + &spec.path, + spec.sha.as_str(), + auth, + ) + .await + .with_context(|| { + format!( + "failed to fetch Azure Repos import manifest `{}/{}/{}@{}`", + spec.owner, + spec.repo, + spec.path, + spec.sha.as_str() + ) + }) + } +} + +/// Routes each import to the correct fetcher based on its typed endpoint, +/// guaranteeing the compile-time fetch source matches the runtime checkout +/// source (see [`crate::compile::imports::alias`]). +/// +/// - endpoint-less / [`ImportEndpoint::AzureReposCrossOrg`] → [`AdoRepoFetcher`] +/// - [`ImportEndpoint::GitHub`] / [`ImportEndpoint::GitHubEnterprise`] → [`GhCliFetcher`] +/// +/// **Fail-closed:** an Azure-Repos-intended (endpoint-less) import can never be +/// silently served by GitHub, eliminating the source-confusion class of bug. +pub struct RoutingFetcher { + ado: AdoRepoFetcher, + github: GhCliFetcher, +} + +impl RoutingFetcher { + pub fn new(ado: AdoRepoFetcher) -> Self { + Self { + ado, + github: GhCliFetcher, + } + } +} + +/// Which fetcher a given endpoint routes to. Extracted as a pure function so +/// the source-confusion guard (an Azure-Repos-intended import must never be +/// served by GitHub, and vice-versa) can be unit-tested without any network. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FetcherKind { + /// Azure Repos (same-org endpoint-less, or cross-org). + AzureRepos, + /// GitHub.com or GitHub Enterprise. + GitHub, +} + +/// Classify an import endpoint to its fetcher. Endpoint-less imports are +/// same-org Azure Repos (primary); this is the single source of truth that +/// keeps compile-time fetch routing aligned with the runtime checkout in +/// [`crate::compile::imports::alias`]. +pub fn route_endpoint(endpoint: &Option) -> FetcherKind { + match endpoint { + None | Some(ImportEndpoint::AzureReposCrossOrg { .. }) => FetcherKind::AzureRepos, + Some(ImportEndpoint::GitHub { .. }) | Some(ImportEndpoint::GitHubEnterprise { .. }) => { + FetcherKind::GitHub + } + } +} + +#[async_trait] +impl ManifestFetcher for RoutingFetcher { + async fn fetch(&self, spec: &ParsedImportSpec) -> Result> { + match route_endpoint(&spec.endpoint) { + FetcherKind::AzureRepos => self.ado.fetch(spec).await, + FetcherKind::GitHub => self.github.fetch(spec).await, + } + } +} + /// A resolved import manifest plus source provenance. #[derive(Debug, Clone)] pub struct ResolvedImport { @@ -115,12 +280,12 @@ pub struct ImportProvenance { /// Prefer [`resolve_imports_with_repo_root`] when the workflow directory is not /// the repository root. This function is kept as the simple public entry point /// for callers that compile from the repo root. -pub fn resolve_imports( +pub async fn resolve_imports( entries: &[ImportEntry], base_dir: &Path, fetcher: &dyn ManifestFetcher, ) -> Result> { - resolve_imports_with_repo_root(entries, base_dir, base_dir, fetcher) + resolve_imports_with_repo_root(entries, base_dir, base_dir, fetcher).await } /// Resolve top-level imports using an explicit repo root for the committed @@ -129,7 +294,7 @@ pub fn resolve_imports( /// TODO: nested imports (depth<=3). This pass intentionally resolves only the /// workflow's declared top-level `imports:` list; transitive resolution will /// layer on this entry point in a later merge pass. -pub fn resolve_imports_with_repo_root( +pub async fn resolve_imports_with_repo_root( entries: &[ImportEntry], base_dir: &Path, repo_root: &Path, @@ -146,6 +311,7 @@ pub fn resolve_imports_with_repo_root( let mut resolved = Vec::new(); for entry in entries { if let Some(import) = resolve_one(entry, base_dir, repo_root, fetcher) + .await .with_context(|| format!("failed to resolve import `{}`", entry.uses))? { resolved.push(import); @@ -154,7 +320,7 @@ pub fn resolve_imports_with_repo_root( Ok(resolved) } -fn resolve_one( +async fn resolve_one( entry: &ImportEntry, base_dir: &Path, repo_root: &Path, @@ -194,7 +360,7 @@ fn resolve_one( })) } ImportSource::Remote(spec) => { - let bytes = read_remote_manifest(repo_root, spec, fetcher)?; + let bytes = read_remote_manifest(repo_root, spec, fetcher).await?; let digest = sha256_hex(&bytes); let (front_matter, body) = parse_manifest_bytes(&bytes, spec.section.as_deref())?; Ok(Some(ResolvedImport { @@ -235,7 +401,7 @@ fn resolve_local_path(base_dir: &Path, import_path: &str) -> Result { Ok(base_dir.join(path)) } -fn read_remote_manifest( +async fn read_remote_manifest( repo_root: &Path, spec: &ParsedImportSpec, fetcher: &dyn ManifestFetcher, @@ -266,7 +432,7 @@ fn read_remote_manifest( return Ok(bytes); } - let bytes = fetcher.fetch(spec)?; + let bytes = fetcher.fetch(spec).await?; enforce_manifest_size( bytes.len(), &format!("{}/{}/{}", spec.owner, spec.repo, spec.path), @@ -491,16 +657,18 @@ mod tests { bytes: Vec, } + #[async_trait] impl ManifestFetcher for StaticFetcher { - fn fetch(&self, _spec: &ParsedImportSpec) -> Result> { + async fn fetch(&self, _spec: &ParsedImportSpec) -> Result> { Ok(self.bytes.clone()) } } struct PanicFetcher; + #[async_trait] impl ManifestFetcher for PanicFetcher { - fn fetch(&self, _spec: &ParsedImportSpec) -> Result> { + async fn fetch(&self, _spec: &ParsedImportSpec) -> Result> { panic!("fetcher must not be called on cache hit") } } @@ -517,8 +685,8 @@ mod tests { b"---\nimport-schema:\n region:\n type: string\n---\n# Imported\nBody\n" } - #[test] - fn local_import_resolves_front_matter_body_and_provenance() { + #[tokio::test] + async fn local_import_resolves_front_matter_body_and_provenance() { let repo = tempfile::tempdir().unwrap(); let workflow_dir = repo.path().join("workflows"); fs::create_dir_all(&workflow_dir).unwrap(); @@ -531,6 +699,7 @@ mod tests { repo.path(), &PanicFetcher, ) + .await .unwrap(); assert_eq!(resolved.len(), 1); @@ -544,8 +713,8 @@ mod tests { ); } - #[test] - fn remote_import_fetches_writes_cache_attributes_and_records_digest() { + #[tokio::test] + async fn remote_import_fetches_writes_cache_attributes_and_records_digest() { let repo = tempfile::tempdir().unwrap(); let entry = import_entry(&format!("acme/shared/components/deploy.md@{SHA}")); let fetcher = StaticFetcher { @@ -553,7 +722,9 @@ mod tests { }; let resolved = - resolve_imports_with_repo_root(&[entry], repo.path(), repo.path(), &fetcher).unwrap(); + resolve_imports_with_repo_root(&[entry], repo.path(), repo.path(), &fetcher) + .await + .unwrap(); let cache_file = repo .path() @@ -584,8 +755,8 @@ mod tests { ); } - #[test] - fn remote_import_uses_cache_before_fetching() { + #[tokio::test] + async fn remote_import_uses_cache_before_fetching() { let repo = tempfile::tempdir().unwrap(); let entry = import_entry(&format!("acme/shared/components/deploy.md@{SHA}")); let cache_dir = repo @@ -600,14 +771,15 @@ mod tests { let resolved = resolve_imports_with_repo_root(&[entry], repo.path(), repo.path(), &PanicFetcher) + .await .unwrap(); assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].body, "# Imported\nBody"); } - #[test] - fn size_cap_rejects_large_manifest() { + #[tokio::test] + async fn size_cap_rejects_large_manifest() { let repo = tempfile::tempdir().unwrap(); let entry = import_entry(&format!("acme/shared/component.md@{SHA}")); let fetcher = StaticFetcher { @@ -615,13 +787,14 @@ mod tests { }; let err = resolve_imports_with_repo_root(&[entry], repo.path(), repo.path(), &fetcher) + .await .unwrap_err(); assert!(format!("{err:#}").contains("exceeding the 262144 byte limit")); } - #[test] - fn nested_imports_in_component_are_rejected() { + #[tokio::test] + async fn nested_imports_in_component_are_rejected() { let repo = tempfile::tempdir().unwrap(); fs::write( repo.path().join("component.md"), @@ -635,6 +808,7 @@ mod tests { repo.path(), &PanicFetcher, ) + .await .unwrap_err(); assert!( format!("{err:#}").contains("nested (transitive) imports are not yet supported"), @@ -642,8 +816,8 @@ mod tests { ); } - #[test] - fn tampered_cache_is_rejected_via_digest_sidecar() { + #[tokio::test] + async fn tampered_cache_is_rejected_via_digest_sidecar() { let repo = tempfile::tempdir().unwrap(); let entry = import_entry(&format!("acme/shared/components/deploy.md@{SHA}")); let fetcher = StaticFetcher { @@ -651,6 +825,7 @@ mod tests { }; // First resolve populates the cache + digest sidecar. resolve_imports_with_repo_root(std::slice::from_ref(&entry), repo.path(), repo.path(), &fetcher) + .await .unwrap(); // Tamper with the committed cache file (sidecar still records the @@ -667,6 +842,7 @@ mod tests { let err = resolve_imports_with_repo_root(&[entry], repo.path(), repo.path(), &PanicFetcher) + .await .unwrap_err(); assert!( format!("{err:#}").contains("does not match its recorded digest"), @@ -674,8 +850,8 @@ mod tests { ); } - #[test] - fn section_selector_extracts_only_that_section() { + #[tokio::test] + async fn section_selector_extracts_only_that_section() { let repo = tempfile::tempdir().unwrap(); let workflow_dir = repo.path(); fs::write( @@ -690,13 +866,14 @@ mod tests { repo.path(), &PanicFetcher, ) + .await .unwrap(); assert_eq!(resolved[0].body, "## Two\ntwo\n### Detail\nkeep"); } - #[test] - fn optional_missing_local_import_is_skipped_and_required_missing_errors() { + #[tokio::test] + async fn optional_missing_local_import_is_skipped_and_required_missing_errors() { let repo = tempfile::tempdir().unwrap(); let optional = resolve_imports_with_repo_root( @@ -705,6 +882,7 @@ mod tests { repo.path(), &PanicFetcher, ) + .await .unwrap(); assert!(optional.is_empty()); @@ -714,6 +892,7 @@ mod tests { repo.path(), &PanicFetcher, ) + .await .unwrap_err(); assert!( err.to_string() @@ -721,8 +900,8 @@ mod tests { ); } - #[test] - fn local_import_rejects_path_traversal() { + #[tokio::test] + async fn local_import_rejects_path_traversal() { let repo = tempfile::tempdir().unwrap(); for spec in ["../secret.md", "../../etc/passwd.md", "a/../../b.md", "./x.md"] { let err = resolve_imports_with_repo_root( @@ -731,6 +910,7 @@ mod tests { repo.path(), &PanicFetcher, ) + .await .unwrap_err(); assert!( format!("{err:#}").contains("invalid segment"), @@ -739,14 +919,15 @@ mod tests { } } - #[test] - fn imports_per_workflow_limit_is_enforced() { + #[tokio::test] + async fn imports_per_workflow_limit_is_enforced() { let repo = tempfile::tempdir().unwrap(); let entries: Vec = (0..=MAX_IMPORTS_PER_WORKFLOW) .map(|idx| import_entry(&format!("component-{idx}.md?"))) .collect(); let err = resolve_imports_with_repo_root(&entries, repo.path(), repo.path(), &PanicFetcher) + .await .unwrap_err(); assert!( @@ -754,4 +935,89 @@ mod tests { .contains("imports per workflow must be <= 20") ); } + + use crate::compile::types::ImportEndpoint; + use crate::secure::{CommitSha, HostName}; + + fn remote_spec(endpoint: Option) -> ParsedImportSpec { + ParsedImportSpec { + owner: "proj".to_string(), + repo: "repo".to_string(), + path: "component.md".to_string(), + sha: CommitSha::parse(SHA).unwrap(), + section: None, + optional: false, + endpoint, + } + } + + #[test] + fn route_endpoint_maps_azure_repos_sources_to_azure_fetcher() { + // Endpoint-less => same-org Azure Repos (the primary, default source). + assert_eq!(route_endpoint(&None), FetcherKind::AzureRepos); + // Cross-org Azure Repos. + assert_eq!( + route_endpoint(&Some(ImportEndpoint::AzureReposCrossOrg { + name: "conn".to_string(), + org: "https://dev.azure.com/other".to_string(), + })), + FetcherKind::AzureRepos + ); + } + + #[test] + fn route_endpoint_maps_github_sources_to_github_fetcher() { + assert_eq!( + route_endpoint(&Some(ImportEndpoint::GitHub { + name: "gh-conn".to_string(), + })), + FetcherKind::GitHub + ); + assert_eq!( + route_endpoint(&Some(ImportEndpoint::GitHubEnterprise { + name: "ghe-conn".to_string(), + host: HostName::parse("api.acme.ghe.com").unwrap(), + })), + FetcherKind::GitHub + ); + } + + /// Fail-closed guard: an endpoint-less (same-org Azure Repos) import whose + /// org/auth could not be resolved must hard-error — it must NEVER silently + /// fall through to GitHub. Regression guard for the source-confusion bug. + #[tokio::test] + async fn ado_fetcher_endpoint_less_without_org_fails_closed() { + let fetcher = AdoRepoFetcher::new( + Err("no ADO remote".to_string()), + Err("no creds".to_string()), + ); + let err = fetcher + .fetch(&remote_spec(None)) + .await + .expect_err("must fail closed without org"); + let msg = format!("{err:#}"); + assert!( + msg.contains("same-org Azure Repos") && msg.contains("no ADO remote"), + "unexpected error: {msg}" + ); + } + + /// A GitHub-typed spec must never be accepted by the Azure Repos fetcher. + #[tokio::test] + async fn ado_fetcher_rejects_github_typed_spec() { + let fetcher = AdoRepoFetcher::new( + Ok("https://dev.azure.com/org".to_string()), + Ok(crate::ado::AdoAuth::Pat("x".to_string())), + ); + let err = fetcher + .fetch(&remote_spec(Some(ImportEndpoint::GitHub { + name: "gh".to_string(), + }))) + .await + .expect_err("azure fetcher must reject a github-typed import"); + assert!( + format!("{err:#}").contains("internal routing error"), + "unexpected error: {err:#}" + ); + } } diff --git a/src/compile/mod.rs b/src/compile/mod.rs index f1f5b9de..12c7f007 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -160,7 +160,44 @@ async fn compile_pipeline_inner( if !front_matter.imports.is_empty() { let base_dir = input_path.parent().unwrap_or_else(|| Path::new(".")); let repo_root = find_repo_root(base_dir).unwrap_or_else(|| base_dir.to_path_buf()); - let fetcher = crate::compile::imports::GhCliFetcher; + + // Build the routing fetcher: endpoint-less / cross-org Azure Repos + // imports resolve via the ADO Git Items API (primary); GitHub/GHE + // imports resolve via `gh`. Azure org URL + auth are resolved + // best-effort here (only when an Azure Repos import is actually + // declared) and any failure is deferred fail-closed to the point the + // affected import is fetched — so a GitHub-only import set, or a + // workflow with no ADO remote, is unaffected. + use crate::compile::types::ImportEndpoint; + let has_same_org_azure = front_matter.imports.iter().any(|e| e.endpoint.is_none()); + let has_any_azure = front_matter.imports.iter().any(|e| { + matches!( + e.endpoint, + None | Some(ImportEndpoint::AzureReposCrossOrg { .. }) + ) + }); + + let ado_fetcher = if has_any_azure { + let context_org_url = if has_same_org_azure { + crate::ado::resolve_ado_context(&repo_root, None, None) + .await + .map(|ctx| ctx.org_url) + .map_err(|e| format!("{e:#}")) + } else { + Err("no same-org Azure Repos import declared".to_string()) + }; + let auth = crate::ado::resolve_auth_non_interactive() + .await + .map_err(|e| format!("{e:#}")); + crate::compile::imports::AdoRepoFetcher::new(context_org_url, auth) + } else { + crate::compile::imports::AdoRepoFetcher::new( + Err("no Azure Repos import declared".to_string()), + Err("no Azure Repos import declared".to_string()), + ) + }; + let fetcher = crate::compile::imports::RoutingFetcher::new(ado_fetcher); + let mut merged_mapping = front_matter_mapping.clone(); markdown_body = crate::compile::imports::merge::merge_imports( &mut merged_mapping, @@ -169,7 +206,8 @@ async fn compile_pipeline_inner( base_dir, &repo_root, &fetcher, - )?; + ) + .await?; front_matter = serde_yaml::from_value(serde_yaml::Value::Mapping(merged_mapping)) .context("Failed to parse front matter after merging imports")?; } diff --git a/src/compile/types.rs b/src/compile/types.rs index 0f216490..3c16271b 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -1192,6 +1192,153 @@ impl FrontMatter { } } +/// Typed cross-repository import endpoint. +/// +/// An **absent** endpoint (`None` on [`ImportEntry`]) denotes a +/// **same-organization Azure Repos** source — the primary, default case for +/// this ADO-native compiler. An **explicit** endpoint selects a different +/// source kind and names the ADO service connection the generated runtime +/// repository resource authenticates with. +/// +/// Deserializes from either a bare string (shorthand for a GitHub.com service +/// connection) or an object with `name`, an optional `type` +/// (`github` | `ghe` | `azure-repos`, defaulting to `github`), and the +/// type-specific `host` (GHE) or `org` (cross-org Azure Repos) field. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ImportEndpoint { + /// GitHub.com, authenticated at runtime via ADO service connection `name`. + GitHub { + /// ADO service connection name. + name: String, + }, + /// GitHub Enterprise Server at API host `host`, via service connection `name`. + GitHubEnterprise { + /// ADO service connection name. + name: String, + /// GHE API host (e.g. `api.acme.ghe.com`). + host: crate::secure::HostName, + }, + /// Azure Repos in a *different* organization `org` (a full collection URL, + /// e.g. `https://dev.azure.com/otherorg`), via service connection `name`. + AzureReposCrossOrg { + /// ADO service connection name. + name: String, + /// Target organization collection URL. + org: String, + }, +} + +impl<'de> Deserialize<'de> for ImportEndpoint { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + use serde::de; + + struct EndpointVisitor; + + impl<'de> de::Visitor<'de> for EndpointVisitor { + type Value = ImportEndpoint; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str( + "a service connection name string, or an object with `name`, optional \ + `type` (github|ghe|azure-repos), and a type-specific `host`/`org`", + ) + } + + fn visit_str( + self, + value: &str, + ) -> std::result::Result { + if value.trim().is_empty() { + return Err(E::custom("import endpoint name must not be empty")); + } + Ok(ImportEndpoint::GitHub { + name: value.to_string(), + }) + } + + fn visit_map(self, map: M) -> std::result::Result + where + M: de::MapAccess<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct EndpointObject { + name: String, + #[serde(default, rename = "type")] + kind: Option, + #[serde(default)] + host: Option, + #[serde(default)] + org: Option, + } + + let obj = EndpointObject::deserialize(de::value::MapAccessDeserializer::new(map))?; + if obj.name.trim().is_empty() { + return Err(de::Error::custom("import endpoint `name` must not be empty")); + } + let kind = obj.kind.as_deref().unwrap_or("github"); + match kind { + "github" => { + if obj.host.is_some() || obj.org.is_some() { + return Err(de::Error::custom( + "`host`/`org` are not valid for import endpoint type `github`", + )); + } + Ok(ImportEndpoint::GitHub { name: obj.name }) + } + "ghe" => { + if obj.org.is_some() { + return Err(de::Error::custom( + "`org` is not valid for import endpoint type `ghe`", + )); + } + let host = obj.host.ok_or_else(|| { + de::Error::custom("import endpoint type `ghe` requires `host`") + })?; + let host = crate::secure::HostName::parse(host) + .map_err(|e| de::Error::custom(e.to_string()))?; + Ok(ImportEndpoint::GitHubEnterprise { + name: obj.name, + host, + }) + } + "azure-repos" => { + if obj.host.is_some() { + return Err(de::Error::custom( + "`host` is not valid for import endpoint type `azure-repos`", + )); + } + let org = obj.org.ok_or_else(|| { + de::Error::custom( + "import endpoint type `azure-repos` requires `org` \ + (the target organization collection URL)", + ) + })?; + if org.trim().is_empty() { + return Err(de::Error::custom( + "import endpoint `org` must not be empty", + )); + } + Ok(ImportEndpoint::AzureReposCrossOrg { + name: obj.name, + org, + }) + } + other => Err(de::Error::custom(format!( + "unknown import endpoint type `{other}`; expected \ + `github`, `ghe`, or `azure-repos`" + ))), + } + } + } + + deserializer.deserialize_any(EndpointVisitor) + } +} + /// A single `imports:` entry — either a bare spec string or an object form. #[derive(Debug, Clone, PartialEq)] pub struct ImportEntry { @@ -1199,8 +1346,8 @@ pub struct ImportEntry { pub uses: String, /// Non-secret input values for the imported workflow schema. pub with: serde_json::Map, - /// Optional ADO service connection for GitHub/GHE remote sources. - pub endpoint: Option, + /// Typed remote source endpoint. `None` denotes same-org Azure Repos. + pub endpoint: Option, } impl<'de> Deserialize<'de> for ImportEntry { @@ -1217,7 +1364,7 @@ impl<'de> Deserialize<'de> for ImportEntry { #[serde(default)] with: serde_json::Map, #[serde(default)] - endpoint: Option, + endpoint: Option, } struct ImportEntryVisitor; @@ -1293,6 +1440,9 @@ pub struct ParsedImportSpec { pub section: Option, /// Whether a missing import should be tolerated by later resolution. pub optional: bool, + /// Typed source endpoint carried from the [`ImportEntry`]. `None` denotes + /// same-org Azure Repos (the primary compile-time fetch source). + pub endpoint: Option, } impl ImportEntry { @@ -1360,6 +1510,7 @@ impl ImportEntry { sha, section, optional, + endpoint: self.endpoint.clone(), })) } } @@ -2996,8 +3147,10 @@ endpoint: shared-components-connection .unwrap(); assert_eq!( - entry.endpoint.as_deref(), - Some("shared-components-connection") + entry.endpoint, + Some(ImportEndpoint::GitHub { + name: "shared-components-connection".to_string() + }) ); assert_eq!( entry.with.get("region").and_then(|v| v.as_str()), @@ -3016,6 +3169,100 @@ endpoint: shared-components-connection } } + #[test] + fn test_import_endpoint_object_github_explicit() { + let entry: ImportEntry = serde_yaml::from_str(&format!( + "uses: acme/shared/deploy.md@{IMPORT_SHA}\nendpoint:\n name: gh-conn\n type: github\n" + )) + .unwrap(); + assert_eq!( + entry.endpoint, + Some(ImportEndpoint::GitHub { + name: "gh-conn".to_string() + }) + ); + // parse_source threads the endpoint into the spec. + match entry.parse_source().unwrap() { + ImportSource::Remote(spec) => assert_eq!(spec.endpoint, entry.endpoint), + other => panic!("expected remote import, got {other:?}"), + } + } + + #[test] + fn test_import_endpoint_object_ghe_requires_host() { + let entry: ImportEntry = serde_yaml::from_str(&format!( + "uses: acme/shared/deploy.md@{IMPORT_SHA}\n\ + endpoint:\n name: ghe-conn\n type: ghe\n host: api.acme.ghe.com\n" + )) + .unwrap(); + match entry.endpoint { + Some(ImportEndpoint::GitHubEnterprise { name, host }) => { + assert_eq!(name, "ghe-conn"); + assert_eq!(host.as_str(), "api.acme.ghe.com"); + } + other => panic!("expected GHE endpoint, got {other:?}"), + } + + // `ghe` without `host` must be rejected. + let err = serde_yaml::from_str::(&format!( + "uses: acme/shared/deploy.md@{IMPORT_SHA}\nendpoint:\n name: ghe-conn\n type: ghe\n" + )) + .unwrap_err(); + assert!(err.to_string().contains("requires `host`"), "{err}"); + } + + #[test] + fn test_import_endpoint_object_azure_repos_requires_org() { + let entry: ImportEntry = serde_yaml::from_str(&format!( + "uses: proj/repo/deploy.md@{IMPORT_SHA}\n\ + endpoint:\n name: xorg-conn\n type: azure-repos\n org: https://dev.azure.com/other\n" + )) + .unwrap(); + assert_eq!( + entry.endpoint, + Some(ImportEndpoint::AzureReposCrossOrg { + name: "xorg-conn".to_string(), + org: "https://dev.azure.com/other".to_string(), + }) + ); + + // `azure-repos` without `org` must be rejected. + let err = serde_yaml::from_str::(&format!( + "uses: proj/repo/deploy.md@{IMPORT_SHA}\n\ + endpoint:\n name: xorg-conn\n type: azure-repos\n" + )) + .unwrap_err(); + assert!(err.to_string().contains("requires `org`"), "{err}"); + } + + #[test] + fn test_import_endpoint_rejects_unknown_type_and_mismatched_fields() { + // Unknown type. + let err = serde_yaml::from_str::(&format!( + "uses: o/r/p.md@{IMPORT_SHA}\nendpoint:\n name: c\n type: bitbucket\n" + )) + .unwrap_err(); + assert!(err.to_string().contains("unknown import endpoint type"), "{err}"); + + // `org` on a github endpoint. + let err = serde_yaml::from_str::(&format!( + "uses: o/r/p.md@{IMPORT_SHA}\nendpoint:\n name: c\n type: github\n org: x\n" + )) + .unwrap_err(); + assert!(err.to_string().contains("not valid for import endpoint type `github`"), "{err}"); + } + + #[test] + fn test_import_endpoint_absent_is_same_org_azure_repos() { + let entry: ImportEntry = + serde_yaml::from_str(&format!("uses: proj/repo/deploy.md@{IMPORT_SHA}\n")).unwrap(); + assert_eq!(entry.endpoint, None); + match entry.parse_source().unwrap() { + ImportSource::Remote(spec) => assert_eq!(spec.endpoint, None), + other => panic!("expected remote import, got {other:?}"), + } + } + #[test] fn test_import_section_and_optional_marker_parse() { let entry = bare_import(format!("owner/repo/path.md@{IMPORT_SHA}#Deploy?")); @@ -3094,8 +3341,10 @@ imports: ImportSource::Local { .. } )); assert_eq!( - fm.imports[1].endpoint.as_deref(), - Some("shared-components-connection") + fm.imports[1].endpoint, + Some(ImportEndpoint::GitHub { + name: "shared-components-connection".to_string() + }) ); assert_eq!( fm.imports[1].with.get("region").and_then(|v| v.as_str()), From 6f79bdb7d67a9b6f366416b253b1aa2585a5f7e1 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 15 Jul 2026 14:18:53 +0100 Subject: [PATCH 05/12] feat(compile): inline imported bodies + move import-inputs to {{ }} delimiter Two coupled changes to how reusable `imports:` deliver content: 1. Delimiter: `${{ ado.aw.import-inputs. }}` -> `{{ ado.aw.import-inputs. }}`. `${{ }}` is Azure DevOps' own template-expression delimiter, and our substituted output is embedded directly into pipeline YAML / the agent prompt where ADO template-processes any `${{ }}` it sees -- a footgun. The compile-time `{{ }}` family is inert there. A `{{` preceded by `$` is left verbatim so genuine `${{ parameters.x }}` is untouched. A new leftover-guard fails compile on any unresolved `{{ ado.aw.import-inputs.* }}` (a body/FM reference to an input the consumer did not supply and the schema did not default) -- closing the leak vector regardless of delimiter. 2. Body delivery: imported component bodies are now inlined into the agent prompt at compile time (they are substituted only at compile time and cannot be re-derived at runtime from the consumer's own source). In the default `inlined-imports: false` mode the consumer body remains a `{{#runtime-import}}` marker, with imported bodies inlined ahead of it (imports-first). Previously the default mode discarded the merged body entirely, silently dropping imported prose + substitutions. Mirrors gh-aw, which compile-inlines input-bearing imports and runtime-imports only the main body. merge_imports now returns (imported_body, combined_body); the imported body is threaded via CompileContext to build_agent_content. Verified end-to-end: a compiled workflow inlines the substituted imported body before the consumer's runtime-import marker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f --- docs/front-matter.md | 7 +- docs/imports.md | 25 ++- src/compile/agentic_pipeline.rs | 78 ++++++++- src/compile/extensions/ado_aw_marker.rs | 10 ++ src/compile/extensions/mod.rs | 8 + src/compile/imports/integration_tests.rs | 6 +- src/compile/imports/merge.rs | 80 +++++++-- src/compile/imports/schema.rs | 208 ++++++++++++++++++++--- src/compile/job.rs | 4 +- src/compile/mod.rs | 23 ++- src/compile/onees.rs | 4 +- src/compile/stage.rs | 4 +- src/compile/standalone.rs | 4 +- 13 files changed, 406 insertions(+), 55 deletions(-) diff --git a/docs/front-matter.md b/docs/front-matter.md index a57670df..d6a90fe3 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -255,8 +255,11 @@ runtime — write it as clear, structured natural-language instructions. `imports:` lets a workflow reuse local or SHA-pinned cross-repository markdown components. Each imported file is parsed as regular ado-aw markdown with YAML front matter; the compiler validates optional `import-schema:` inputs, applies -`${{ ado.aw.import-inputs. }}` substitutions, then merges the imported -front matter and body into the consumer workflow. +`{{ ado.aw.import-inputs. }}` substitutions (a compile-time `{{ ... }}` +replacement — not the ADO `${{ ... }}` template delimiter), then merges the +imported front matter and body into the consumer workflow. Imported **body** +content is inlined into the agent prompt at compile time (ahead of the +consumer's own body); see [`imports.md`](imports.md) for the full reference. ```yaml imports: diff --git a/docs/imports.md b/docs/imports.md index 3d9d3828..f1367bd0 100644 --- a/docs/imports.md +++ b/docs/imports.md @@ -138,9 +138,19 @@ Current MVP notes: A reusable component can declare non-secret inputs with `import-schema:`. Consumers pass values through `with:`. Values are validated at compile time, defaults are applied, and placeholders of the form -`${{ ado.aw.import-inputs. }}` are substituted throughout the imported front +`{{ ado.aw.import-inputs. }}` are substituted throughout the imported front matter and body before merge. +> **Delimiter.** Import inputs use the compile-time `{{ ... }}` delimiter (the +> same family as `{{ workspace }}`), **not** the Azure DevOps template-expression +> delimiter `${{ ... }}`. The substituted output is embedded directly into the +> pipeline YAML and agent prompt, where ADO template-processes any `${{ ... }}` +> it finds — so reusing that delimiter would be a footgun. A `{{` immediately +> preceded by `$` is treated as an ADO `${{ ... }}` expression and left +> untouched. Any `{{ ado.aw.import-inputs. }}` still present after +> substitution (an input the consumer did not supply and the schema did not +> default) is a **compile-time error**. + Supported schema types are `string`, `number`, `boolean`, `choice`, `array`, and `object`. `choice` uses an `options:` list. `array` uses an `items:` schema. `object` uses `properties:`; object properties are currently one level deep. @@ -185,8 +195,8 @@ safe-outputs: env: NOTIFY_TOKEN: TEAM_NOTIFY_TOKEN --- -When notifying the team, use channel `${{ ado.aw.import-inputs.channel }}` and -severity `${{ ado.aw.import-inputs.severity }}`. +When notifying the team, use channel `{{ ado.aw.import-inputs.channel }}` and +severity `{{ ado.aw.import-inputs.severity }}`. ``` Consumer: @@ -229,7 +239,12 @@ consumer workflow > later import > earlier import `require-approval`, but may not replace executor-defining fields such as `steps`, `env`, `inputs`, `run`, or `entrypoint`. - Imported markdown bodies are concatenated in declaration order, followed by - the consumer body. + the consumer body. Imported bodies are **inlined into the agent prompt at + compile time** (their `{{ ado.aw.import-inputs.* }}` placeholders are already + substituted); in the default `inlined-imports: false` mode the consumer's own + body is delivered ahead-of-time as a `{{#runtime-import}}` marker so it can + still be edited without recompiling, while imported bodies — which can only be + substituted at compile time — precede it inline. - `import-schema:` and `imports:` are consumed by the merge and do not appear in the merged workflow. @@ -277,7 +292,7 @@ safe-outputs: displayName: Create service ticket --- Use `create-service-ticket` only when a durable service-desk record is needed -for `${{ ado.aw.import-inputs.service }}`. +for `{{ ado.aw.import-inputs.service }}`. ``` Consumer workflow: diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 7fda4b60..83bc85c6 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -302,6 +302,7 @@ pub(crate) fn build_pipeline_context( front_matter, input_path, markdown_body, + &ctx.imported_prompt_body, &source_path, &trigger_repo_directory, )?; @@ -4329,12 +4330,21 @@ fn step_value_to_dash_yaml(v: serde_yaml::Value) -> Result { Ok(out) } -/// Build the agent prompt body — either inlined imports or a -/// runtime-import marker. Mirrors `compile_shared`'s logic. +/// Build the agent prompt body. +/// +/// In `inlined-imports: true` mode the entire body (imported + consumer) is +/// already in `markdown_body`, so it is resolved inline verbatim. In the +/// default mode the consumer body is delivered by a `{{#runtime-import}}` +/// marker (so authors can edit it without recompiling), but any imported +/// component bodies (`imported_prompt_body`) are inlined **ahead** of that +/// marker: they were substituted at compile time and cannot be re-derived at +/// runtime from the consumer's own source. Mirrors gh-aw, which compile-inlines +/// input-bearing imports and runtime-imports only the main body. fn build_agent_content( front_matter: &FrontMatter, input_path: &Path, markdown_body: &str, + imported_prompt_body: &str, source_path: &str, trigger_repo_directory: &str, ) -> Result { @@ -4365,7 +4375,15 @@ fn build_agent_content( "runtime-import: agent source path '{}' contains '}}', which is not supported by the runtime resolver (rename the path to remove '}}' characters, or set `inlined-imports: true`)", marker_path ); - Ok(format!("{{{{#runtime-import {}}}}}", marker_path)) + let consumer_marker = format!("{{{{#runtime-import {}}}}}", marker_path); + + // Prepend the compile-time-substituted imported component bodies (if any) + // ahead of the consumer's runtime-import marker (imports-first ordering). + if imported_prompt_body.trim().is_empty() { + Ok(consumer_marker) + } else { + Ok(format!("{imported_prompt_body}\n\n{consumer_marker}")) + } } // Suppress unused warnings on imports retained for clarity / future use. @@ -4974,4 +4992,58 @@ description: Test "ubuntu-22.04" ); } + + // ─── build_agent_content: imported-body delivery ───────────────────────── + + #[test] + fn build_agent_content_default_mode_inlines_imported_body_before_marker() { + let fm = test_front_matter("name: t\ndescription: d\n"); + let out = build_agent_content( + &fm, + std::path::Path::new("agents/test.md"), + // markdown_body (combined) is ignored in default mode. + "IGNORED COMBINED BODY", + "Imported guidance line.", + "$(Build.SourcesDirectory)/agents/test.md", + "$(Build.SourcesDirectory)", + ) + .unwrap(); + assert_eq!( + out, + "Imported guidance line.\n\n{{#runtime-import agents/test.md}}" + ); + } + + #[test] + fn build_agent_content_default_mode_without_imports_is_marker_only() { + let fm = test_front_matter("name: t\ndescription: d\n"); + let out = build_agent_content( + &fm, + std::path::Path::new("agents/test.md"), + "IGNORED", + "", + "$(Build.SourcesDirectory)/agents/test.md", + "$(Build.SourcesDirectory)", + ) + .unwrap(); + assert_eq!(out, "{{#runtime-import agents/test.md}}"); + } + + #[test] + fn build_agent_content_inlined_mode_uses_combined_body() { + // In inlined mode the combined body (imported + consumer) is already in + // markdown_body and is emitted verbatim; the separate + // imported_prompt_body arg is not appended a second time. + let fm = test_front_matter("name: t\ndescription: d\ninlined-imports: true\n"); + let out = build_agent_content( + &fm, + std::path::Path::new("agents/test.md"), + "Imported guidance line.\n\nConsumer body.", + "Imported guidance line.", + "$(Build.SourcesDirectory)/agents/test.md", + "$(Build.SourcesDirectory)", + ) + .unwrap(); + assert_eq!(out, "Imported guidance line.\n\nConsumer body."); + } } diff --git a/src/compile/extensions/ado_aw_marker.rs b/src/compile/extensions/ado_aw_marker.rs index 67f6c1e5..97768ada 100644 --- a/src/compile/extensions/ado_aw_marker.rs +++ b/src/compile/extensions/ado_aw_marker.rs @@ -275,6 +275,7 @@ mod tests { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: Some(input_path), + imported_prompt_body: String::new(), }; let steps = agent_prepare_steps(&ctx); assert_eq!(steps.len(), 2); @@ -324,6 +325,7 @@ mod tests { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: Some(input_path), + imported_prompt_body: String::new(), }; let steps = agent_prepare_steps(&ctx); assert_eq!(steps.len(), 2); @@ -405,6 +407,7 @@ mod tests { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: Some(input_path), + imported_prompt_body: String::new(), }; let steps = agent_prepare_steps(&ctx); assert_eq!(steps.len(), 2); @@ -428,6 +431,7 @@ mod tests { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: Some(input_path), + imported_prompt_body: String::new(), }; let component = CustomComponentProvenance { source: "org/repo/components/create-pr".to_string(), @@ -497,6 +501,7 @@ mod tests { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: Some(input_path), + imported_prompt_body: String::new(), }; let steps = agent_prepare_steps(&ctx); assert_eq!(steps.len(), 2); @@ -539,6 +544,7 @@ mod tests { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: Some(input_path), + imported_prompt_body: String::new(), }; let steps = agent_prepare_steps(&ctx); assert_eq!(steps.len(), 2, "target={raw_target}"); @@ -577,6 +583,7 @@ mod tests { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: Some(input_path), + imported_prompt_body: String::new(), }; let decl = AdoAwMarkerExtension::default().declarations(&ctx).unwrap(); assert_eq!(decl.agent_prepare_steps.len(), 2); @@ -613,6 +620,7 @@ mod tests { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: Some(input_path), + imported_prompt_body: String::new(), }; let steps = agent_prepare_steps(&ctx); assert_eq!(steps.len(), 2); @@ -653,6 +661,7 @@ mod tests { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: Some(input_path), + imported_prompt_body: String::new(), }; let steps = agent_prepare_steps(&ctx); assert_eq!(steps.len(), 2); @@ -703,6 +712,7 @@ mod tests { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: Some(input_path), + imported_prompt_body: String::new(), }; let steps = agent_prepare_steps(&ctx); assert_eq!(steps.len(), 2); diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 111b63b5..32beb2aa 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -114,6 +114,10 @@ pub struct CompileContext<'a> { /// Consumed by the always-on `ado-aw-marker` compiler extension to /// embed source-path metadata in the compiled YAML. pub input_path: Option<&'a Path>, + /// Substituted, joined bodies of any imported components, inlined into the + /// agent prompt at compile time. Empty when the workflow declares no + /// imports (or on paths that do not resolve imports, e.g. `check`). + pub imported_prompt_body: String, } impl<'a> CompileContext<'a> { @@ -143,6 +147,7 @@ impl<'a> CompileContext<'a> { engine, compile_dir: Some(compile_dir), input_path: Some(input_path), + imported_prompt_body: String::new(), }) } @@ -193,6 +198,7 @@ impl<'a> CompileContext<'a> { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: None, + imported_prompt_body: String::new(), } } @@ -210,6 +216,7 @@ impl<'a> CompileContext<'a> { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: None, + imported_prompt_body: String::new(), } } @@ -223,6 +230,7 @@ impl<'a> CompileContext<'a> { engine: crate::engine::Engine::Copilot, compile_dir: Some(compile_dir), input_path: None, + imported_prompt_body: String::new(), } } } diff --git a/src/compile/imports/integration_tests.rs b/src/compile/imports/integration_tests.rs index 437c97dd..443f8e7f 100644 --- a/src/compile/imports/integration_tests.rs +++ b/src/compile/imports/integration_tests.rs @@ -181,11 +181,11 @@ import-schema: required: true safe-outputs: deploy: - run: "deploy --to ${{ ado.aw.import-inputs.destination }}" + run: "deploy --to {{ ado.aw.import-inputs.destination }}" env: - DESTINATION: "${{ ado.aw.import-inputs.destination }}" + DESTINATION: "{{ ado.aw.import-inputs.destination }}" --- -Deploy to ${{ ado.aw.import-inputs.destination }}. +Deploy to {{ ado.aw.import-inputs.destination }}. "#, ); let workflow_path = workflow_dir.join("agent.md"); diff --git a/src/compile/imports/merge.rs b/src/compile/imports/merge.rs index 783e46dd..a6bc7cfe 100644 --- a/src/compile/imports/merge.rs +++ b/src/compile/imports/merge.rs @@ -41,10 +41,19 @@ const SEQUENCE_KEYS: &[&str] = &["parameters", "repos", "variable-groups"]; const EXECUTOR_KEYS: &[&str] = &["steps", "env", "inputs", "run", "entrypoint"]; /// Resolve the consumer's imports, apply their `import-schema` inputs, and -/// merge their front matter + body into `consumer_fm` / the returned body. +/// merge their front matter + body into `consumer_fm` / the returned bodies. /// -/// Returns the merged markdown body. `consumer_fm` is mutated in place and its -/// `imports:` key is removed (imports are consumed by this pass). +/// Returns `(imported_body, combined_body)`: +/// - `imported_body` is the substituted, joined bodies of the imported +/// components (declaration order), with the consumer body NOT appended. +/// This is inlined into the agent prompt at compile time because imported +/// component bodies are only substituted here — they cannot be delivered by +/// the default runtime-import path (which reads the consumer's own source). +/// - `combined_body` additionally appends the consumer body, and is what +/// `inlined-imports: true` folds into the compiled YAML. +/// +/// `consumer_fm` is mutated in place and its `imports:` key is removed (imports +/// are consumed by this pass). pub async fn merge_imports( consumer_fm: &mut Mapping, consumer_body: &str, @@ -52,16 +61,45 @@ pub async fn merge_imports( base_dir: &Path, repo_root: &Path, fetcher: &dyn ManifestFetcher, -) -> Result { +) -> Result<(String, String)> { let resolved = resolve_imports_with_repo_root(entries, base_dir, repo_root, fetcher).await?; - merge_resolved(consumer_fm, consumer_body, &resolved) + let imported_body = merge_resolved_imported_body(consumer_fm, &resolved)?; + let combined_body = join_bodies(&imported_body, consumer_body); + Ok((imported_body, combined_body)) +} + +/// Join the imported-body prefix with the consumer body using the same +/// `\n\n` separator as the merge accumulator, tolerating either side being +/// empty. +fn join_bodies(imported_body: &str, consumer_body: &str) -> String { + let consumer_trimmed = consumer_body.trim(); + match (imported_body.is_empty(), consumer_trimmed.is_empty()) { + (true, _) => consumer_trimmed.to_string(), + (false, true) => imported_body.to_string(), + (false, false) => format!("{imported_body}\n\n{consumer_trimmed}"), + } } /// Merge already-resolved imports (test-friendly seam that takes no fetcher). +/// +/// Returns the combined body (imported bodies in declaration order, then the +/// consumer body). Front matter is merged into `consumer_fm`. pub fn merge_resolved( consumer_fm: &mut Mapping, consumer_body: &str, resolved: &[ResolvedImport], +) -> Result { + let imported_body = merge_resolved_imported_body(consumer_fm, resolved)?; + Ok(join_bodies(&imported_body, consumer_body)) +} + +/// Merge resolved imports' front matter into `consumer_fm` and return the +/// substituted, joined **imported** bodies (declaration order) — the consumer +/// body is NOT appended here (see [`merge_imports`] for why the imported body +/// is tracked separately from the consumer body). +pub fn merge_resolved_imported_body( + consumer_fm: &mut Mapping, + resolved: &[ResolvedImport], ) -> Result { // Accumulate imported front matter in declaration order (import-vs-import // rules), then overlay the consumer on top. @@ -104,11 +142,6 @@ pub fn merge_resolved( acc.remove(Value::String("imports".to_string())); *consumer_fm = acc; - // Body: imported bodies (declaration order) then the consumer body. - let consumer_trimmed = consumer_body.trim(); - if !consumer_trimmed.is_empty() { - body_parts.push(consumer_trimmed.to_string()); - } Ok(body_parts.join("\n\n")) } @@ -368,6 +401,33 @@ mod tests { ); } + #[test] + fn imported_body_and_combined_body_split_correctly() { + // merge_resolved_imported_body returns ONLY the imported bodies; the + // combined form (via merge_resolved) additionally appends the consumer + // body. Imports-first ordering. + let mut fm = ymap("name: consumer"); + let imports = vec![resolved("{}", "Import A."), resolved("{}", "Import B.")]; + let imported = merge_resolved_imported_body(&mut fm, &imports).unwrap(); + assert_eq!(imported, "Import A.\n\nImport B."); + + let mut fm2 = ymap("name: consumer"); + let combined = merge_resolved(&mut fm2, "Consumer body.", &imports).unwrap(); + assert_eq!(combined, "Import A.\n\nImport B.\n\nConsumer body."); + } + + #[test] + fn imported_body_empty_when_no_import_bodies() { + let mut fm = ymap("name: consumer"); + let imports = vec![resolved("tools:\n edit: {}", "")]; + let imported = merge_resolved_imported_body(&mut fm, &imports).unwrap(); + assert_eq!(imported, ""); + // Combined with a consumer body yields just the consumer body. + let mut fm2 = ymap("name: consumer"); + let combined = merge_resolved(&mut fm2, "Only consumer.", &imports).unwrap(); + assert_eq!(combined, "Only consumer."); + } + #[test] fn later_import_wins_over_earlier_for_scalars() { let mut consumer = ymap("name: c"); diff --git a/src/compile/imports/schema.rs b/src/compile/imports/schema.rs index 9f02cb3f..2a1db07a 100644 --- a/src/compile/imports/schema.rs +++ b/src/compile/imports/schema.rs @@ -1,5 +1,5 @@ //! `import-schema` modeling, consumer-`with` validation, and -//! `${{ ado.aw.import-inputs. }}` substitution. +//! `{{ ado.aw.import-inputs. }}` substitution. #![allow(dead_code)] use std::collections::BTreeMap; @@ -61,20 +61,34 @@ pub fn validate_with( validate_fields(&schema.fields, with, "") } -/// Substitutes `${{ ado.aw.import-inputs. }}` placeholders in text. +/// Substitutes `{{ ado.aw.import-inputs. }}` placeholders in text. /// -/// Whitespace around the expression inside `${{ ... }}` is allowed. Dotted -/// paths (for example `config.apiKey`) access object sub-fields. Missing keys -/// are intentionally left unchanged so a later validation pass can flag them. +/// This is an ado-aw **compile-time** replacement in the `{{ ... }}` family +/// (like `{{ workspace }}`), deliberately NOT the ADO template-expression +/// delimiter `${{ ... }}`: our substituted output is embedded directly into +/// pipeline YAML, and ADO template-processes any `${{ ... }}` it sees there, so +/// reusing that delimiter would be a footgun. A `{{` immediately preceded by +/// `$` is therefore treated as an ADO `${{ ... }}` expression and passed +/// through verbatim. +/// +/// Whitespace around the expression inside `{{ ... }}` is allowed. Dotted paths +/// (for example `config.apiKey`) access object sub-fields. Missing keys are +/// intentionally left unchanged so [`apply_import_inputs`]'s leftover-placeholder +/// guard can flag them. pub fn substitute_inputs(text: &str, inputs: &JsonMap) -> String { let mut output = String::with_capacity(text.len()); let mut cursor = 0; - while let Some(relative_start) = text[cursor..].find("${{") { + while let Some(relative_start) = text[cursor..].find("{{") { let start = cursor + relative_start; output.push_str(&text[cursor..start]); - let expression_start = start + 3; + // A `{{` immediately preceded by `$` is an ADO `${{ ... }}` template + // expression, never one of our markers — leave it verbatim (the `$` + // was already pushed as part of `text[cursor..start]`). + let preceded_by_dollar = start > 0 && text.as_bytes()[start - 1] == b'$'; + + let expression_start = start + 2; let Some(relative_end) = text[expression_start..].find("}}") else { output.push_str(&text[start..]); return output; @@ -83,6 +97,12 @@ pub fn substitute_inputs(text: &str, inputs: &JsonMap) -> Str let original = &text[start..expression_end + 2]; let expression = text[expression_start..expression_end].trim(); + if preceded_by_dollar { + output.push_str(original); + cursor = expression_end + 2; + continue; + } + match expression.strip_prefix(PLACEHOLDER_PREFIX) { Some(path) if !path.is_empty() => match lookup_input_path(inputs, path) { Some(value) => output.push_str(&render_json_value(value)), @@ -98,6 +118,49 @@ pub fn substitute_inputs(text: &str, inputs: &JsonMap) -> Str output } +/// Scans `text` for an **unresolved** `{{ ado.aw.import-inputs. }}` +/// placeholder and returns its expression (e.g. `ado.aw.import-inputs.missing`) +/// if one remains after substitution. +/// +/// Unlike [`substitute_inputs`], this does NOT skip a `$`-preceded `{{`: an +/// author who mistakenly wrote the old `${{ ado.aw.import-inputs.x }}` form +/// must also be flagged, because ADO would otherwise template-process it in the +/// emitted YAML. Any surviving marker of our namespace is a compile-time error +/// (a body reference to an input not supplied by the consumer `with:` / absent +/// from the component `import-schema:`). +fn find_input_placeholder_leak(text: &str) -> Option { + let mut cursor = 0; + while let Some(relative_start) = text[cursor..].find("{{") { + let start = cursor + relative_start; + let expression_start = start + 2; + let relative_end = text[expression_start..].find("}}")?; + let expression_end = expression_start + relative_end; + let expression = text[expression_start..expression_end].trim(); + if let Some(path) = expression.strip_prefix(PLACEHOLDER_PREFIX) + && !path.is_empty() + { + return Some(expression.to_string()); + } + cursor = expression_end + 2; + } + None +} + +/// Walks front-matter string scalars for an unresolved import-input placeholder. +fn find_front_matter_placeholder_leak(fm: &YamlValue) -> Option { + match fm { + YamlValue::String(s) => find_input_placeholder_leak(s), + YamlValue::Sequence(items) => { + items.iter().find_map(find_front_matter_placeholder_leak) + } + YamlValue::Mapping(mapping) => mapping.iter().find_map(|(key, value)| { + find_front_matter_placeholder_leak(key) + .or_else(|| find_front_matter_placeholder_leak(value)) + }), + _ => None, + } +} + /// Walks front matter and substitutes import-input placeholders in every string /// scalar. pub fn substitute_front_matter(fm: &YamlValue, inputs: &JsonMap) -> YamlValue { @@ -137,10 +200,28 @@ pub fn apply_import_inputs( let inputs = validate_with(&schema, with)?; let stripped_front_matter = strip_import_schema(front_matter); - Ok(( - substitute_front_matter(&stripped_front_matter, &inputs), - substitute_inputs(body, &inputs), - )) + let substituted_front_matter = substitute_front_matter(&stripped_front_matter, &inputs); + let substituted_body = substitute_inputs(body, &inputs); + + // Leftover-placeholder guard: any `{{ ado.aw.import-inputs. }}` still + // present after substitution is a reference to an input the consumer did + // not supply (via `with:`) and that the component `import-schema:` did not + // default. Fail closed — an unresolved marker embedded into the pipeline + // YAML or agent prompt is a footgun (ADO would template-process a stray + // `${{ ... }}`, and a leaked prompt marker is meaningless to the agent). + if let Some(expr) = find_front_matter_placeholder_leak(&substituted_front_matter) + .or_else(|| find_input_placeholder_leak(&substituted_body)) + { + let key = expr.strip_prefix(PLACEHOLDER_PREFIX).unwrap_or(&expr); + anyhow::bail!( + "unresolved import input placeholder `{{{{ {expr} }}}}`: no input named `{key}` \ + was provided in the consumer `with:` or defaulted by the component \ + `import-schema:` (note: use the compile-time `{{{{ ... }}}}` delimiter, not \ + the ADO `${{{{ ... }}}}` template-expression delimiter)" + ); + } + + Ok((substituted_front_matter, substituted_body)) } fn parse_fields( @@ -429,7 +510,7 @@ fn lookup_input_path<'a>( Some(value) } -/// Render an import-input value for interpolation into a `${{ ... }}` +/// Render an import-input value for interpolation into a `{{ ... }}` /// placeholder. /// /// **Trust boundary.** Import inputs are non-secret, compile-time author @@ -626,13 +707,13 @@ import-schema: "config": { "apiKey": "secret" } }); let text = concat!( - "name=${{ado.aw.import-inputs.name}} ", - "key=${{ ado.aw.import-inputs.config.apiKey }} ", - "count=${{ ado.aw.import-inputs.count }} ", - "enabled=${{ ado.aw.import-inputs.enabled }} ", - "tags=${{ ado.aw.import-inputs.tags }} ", - "config=${{ ado.aw.import-inputs.config }} ", - "missing=${{ ado.aw.import-inputs.missing }}" + "name={{ado.aw.import-inputs.name}} ", + "key={{ ado.aw.import-inputs.config.apiKey }} ", + "count={{ ado.aw.import-inputs.count }} ", + "enabled={{ ado.aw.import-inputs.enabled }} ", + "tags={{ ado.aw.import-inputs.tags }} ", + "config={{ ado.aw.import-inputs.config }} ", + "missing={{ ado.aw.import-inputs.missing }}" ); let substituted = substitute_inputs(text, inputs.as_object().unwrap()); @@ -646,18 +727,41 @@ import-schema: "enabled=false ", "tags=[\"a\",\"b\"] ", "config={\"apiKey\":\"secret\"} ", - "missing=${{ ado.aw.import-inputs.missing }}" + "missing={{ ado.aw.import-inputs.missing }}" ) ); } + #[test] + fn substitute_inputs_preserves_ado_template_expressions() { + // A `$`-preceded `{{ ... }}` is an ADO `${{ ... }}` template expression, + // NOT one of our markers — it must pass through untouched, even when it + // syntactically mentions our namespace (that leak is caught later by the + // guard, not silently substituted here). + let inputs = json!({ "name": "demo" }); + let text = "keep ${{ parameters.env }} and {{ ado.aw.import-inputs.name }} and ${{ ado.aw.import-inputs.name }}"; + let substituted = substitute_inputs(text, inputs.as_object().unwrap()); + assert_eq!( + substituted, + "keep ${{ parameters.env }} and demo and ${{ ado.aw.import-inputs.name }}" + ); + } + + #[test] + fn substitute_inputs_preserves_runtime_import_markers() { + let inputs = json!({ "name": "demo" }); + let text = "{{#runtime-import agents/x.md}} uses {{ ado.aw.import-inputs.name }}"; + let substituted = substitute_inputs(text, inputs.as_object().unwrap()); + assert_eq!(substituted, "{{#runtime-import agents/x.md}} uses demo"); + } + #[test] fn substitute_inputs_neutralizes_pipeline_commands_in_string_values() { // A string import input containing an ADO logging command must be // neutralized when interpolated (defense-in-depth), so it cannot smuggle // a `##vso[` pipeline command into a generated step. let inputs = json!({ "evil": "##vso[task.setvariable variable=x]y" }); - let substituted = substitute_inputs("val=${{ ado.aw.import-inputs.evil }}", inputs.as_object().unwrap()); + let substituted = substitute_inputs("val={{ ado.aw.import-inputs.evil }}", inputs.as_object().unwrap()); // The neutralized form wraps the command in backticks so ADO renders it // as inert text instead of executing it. assert!( @@ -674,11 +778,11 @@ import-schema: fn substitute_front_matter_walks_nested_mappings_and_sequences() { let fm = yaml( r#" -name: ${{ ado.aw.import-inputs.name }} +name: "{{ ado.aw.import-inputs.name }}" steps: - - bash: echo ${{ ado.aw.import-inputs.config.apiKey }} + - bash: echo {{ ado.aw.import-inputs.config.apiKey }} nested: - value: before ${{ado.aw.import-inputs.name}} after + value: before {{ado.aw.import-inputs.name}} after "#, ); let inputs = json!({ @@ -712,16 +816,16 @@ import-schema: count: type: number default: 2 -name: component-${{ ado.aw.import-inputs.name }} +name: component-{{ ado.aw.import-inputs.name }} variables: - count: "${{ ado.aw.import-inputs.count }}" + count: "{{ ado.aw.import-inputs.count }}" "#, ); let with = json!({ "name": "demo" }); let (front_matter, body) = apply_import_inputs( &fm, - "Hello ${{ ado.aw.import-inputs.name }} ${{ ado.aw.import-inputs.count }}", + "Hello {{ ado.aw.import-inputs.name }} {{ ado.aw.import-inputs.count }}", with.as_object().unwrap(), ) .unwrap(); @@ -738,4 +842,54 @@ variables: ); assert_eq!(body, "Hello demo 2"); } + + #[test] + fn apply_import_inputs_rejects_unresolved_body_placeholder() { + // `typo` is not in the schema and not supplied — `validate_with` won't + // catch a body-only reference, so the leftover guard must. + let fm = yaml("import-schema:\n name:\n type: string\n"); + let with = json!({ "name": "demo" }); + let err = apply_import_inputs( + &fm, + "Hello {{ ado.aw.import-inputs.typo }}", + with.as_object().unwrap(), + ) + .unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("unresolved import input placeholder"), "{msg}"); + assert!(msg.contains("ado.aw.import-inputs.typo"), "{msg}"); + } + + #[test] + fn apply_import_inputs_rejects_leftover_dollar_delimited_marker() { + // An author who mistakenly used the old `${{ ... }}` form is flagged: + // it would otherwise be template-processed by ADO in the emitted YAML. + let fm = yaml("import-schema:\n name:\n type: string\n"); + let with = json!({ "name": "demo" }); + let err = apply_import_inputs( + &fm, + "Hello ${{ ado.aw.import-inputs.name }}", + with.as_object().unwrap(), + ) + .unwrap_err(); + assert!( + format!("{err:#}").contains("unresolved import input placeholder"), + "{err:#}" + ); + } + + #[test] + fn apply_import_inputs_leaves_ado_template_expression_untouched() { + // A genuine ADO `${{ parameters.x }}` (not our namespace) must survive + // substitution and NOT trip the guard. + let fm = yaml("name: keep\n"); + let with = json!({}); + let (_, body) = apply_import_inputs( + &fm, + "run in ${{ parameters.environment }}", + with.as_object().unwrap(), + ) + .unwrap(); + assert_eq!(body, "run in ${{ parameters.environment }}"); + } } diff --git a/src/compile/job.rs b/src/compile/job.rs index d776ad2e..93664b62 100644 --- a/src/compile/job.rs +++ b/src/compile/job.rs @@ -31,13 +31,15 @@ impl Compiler for JobCompiler { output_path: &Path, front_matter: &FrontMatter, markdown_body: &str, + imported_prompt_body: &str, skip_integrity: bool, debug_pipeline: bool, ) -> Result { info!("Compiling for job target (typed IR)"); let extensions = super::extensions::collect_extensions(front_matter); - let ctx = super::extensions::CompileContext::new(front_matter, input_path).await?; + let mut ctx = super::extensions::CompileContext::new(front_matter, input_path).await?; + ctx.imported_prompt_body = imported_prompt_body.to_string(); let pipeline = super::job_ir::build_job_pipeline( front_matter, diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 12c7f007..a7ef73a5 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -57,12 +57,20 @@ pub use types::{CompileTarget, FrontMatter}; #[async_trait] pub trait Compiler: Send + Sync { /// Compile the front matter and markdown body into pipeline YAML. + /// + /// `imported_prompt_body` carries the substituted, joined bodies of any + /// imported components (empty when there are no imports). It is inlined + /// into the agent prompt at compile time — imported bodies cannot be + /// delivered by the default runtime-import path (which reads only the + /// consumer's own source file). + #[allow(clippy::too_many_arguments)] async fn compile( &self, input_path: &Path, output_path: &Path, front_matter: &FrontMatter, markdown_body: &str, + imported_prompt_body: &str, skip_integrity: bool, debug_pipeline: bool, ) -> Result; @@ -151,6 +159,12 @@ async fn compile_pipeline_inner( let body_raw = parsed.body_raw; let source_sha256 = parsed.source_sha256; + // Substituted, joined bodies of any imported components. Inlined into the + // agent prompt at compile time (they cannot be delivered by the default + // runtime-import path, which reads the consumer's own source). Empty when + // the workflow declares no imports. + let mut imported_prompt_body = String::new(); + // Resolve and merge cross-repository / local `imports:` (D8/D9). Runs only // when the workflow declares imports, so import-free workflows are // unaffected. The merge is applied to a CLONE of the front-matter mapping — @@ -199,7 +213,7 @@ async fn compile_pipeline_inner( let fetcher = crate::compile::imports::RoutingFetcher::new(ado_fetcher); let mut merged_mapping = front_matter_mapping.clone(); - markdown_body = crate::compile::imports::merge::merge_imports( + let (imported, combined) = crate::compile::imports::merge::merge_imports( &mut merged_mapping, &markdown_body, &front_matter.imports, @@ -208,6 +222,8 @@ async fn compile_pipeline_inner( &fetcher, ) .await?; + imported_prompt_body = imported; + markdown_body = combined; front_matter = serde_yaml::from_value(serde_yaml::Value::Mapping(merged_mapping)) .context("Failed to parse front matter after merging imports")?; } @@ -280,6 +296,7 @@ async fn compile_pipeline_inner( &yaml_output_path, &front_matter, &markdown_body, + &imported_prompt_body, skip_integrity, debug_pipeline, ) @@ -692,6 +709,10 @@ pub async fn check_pipeline(pipeline_path: &str) -> Result<()> { pipeline_path, &front_matter, &markdown_body, + // `check_pipeline` does not resolve `imports:` (pre-existing: it + // also skips the front-matter merge), so there is no imported + // prompt body to inline here. + "", false, false, ) diff --git a/src/compile/onees.rs b/src/compile/onees.rs index 424f9e9b..5df45067 100644 --- a/src/compile/onees.rs +++ b/src/compile/onees.rs @@ -35,13 +35,15 @@ impl Compiler for OneESCompiler { output_path: &Path, front_matter: &FrontMatter, markdown_body: &str, + imported_prompt_body: &str, skip_integrity: bool, debug_pipeline: bool, ) -> Result { info!("Compiling for 1ES target (typed IR)"); let extensions = super::extensions::collect_extensions(front_matter); - let ctx = super::extensions::CompileContext::new(front_matter, input_path).await?; + let mut ctx = super::extensions::CompileContext::new(front_matter, input_path).await?; + ctx.imported_prompt_body = imported_prompt_body.to_string(); let pipeline = super::onees_ir::build_onees_pipeline( front_matter, diff --git a/src/compile/stage.rs b/src/compile/stage.rs index e693e650..e5e41389 100644 --- a/src/compile/stage.rs +++ b/src/compile/stage.rs @@ -41,13 +41,15 @@ impl Compiler for StageCompiler { output_path: &Path, front_matter: &FrontMatter, markdown_body: &str, + imported_prompt_body: &str, skip_integrity: bool, debug_pipeline: bool, ) -> Result { info!("Compiling for stage target (typed IR)"); let extensions = super::extensions::collect_extensions(front_matter); - let ctx = super::extensions::CompileContext::new(front_matter, input_path).await?; + let mut ctx = super::extensions::CompileContext::new(front_matter, input_path).await?; + ctx.imported_prompt_body = imported_prompt_body.to_string(); let pipeline = super::stage_ir::build_stage_pipeline( front_matter, diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 56afdc41..d7a4f3d3 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -30,13 +30,15 @@ impl Compiler for StandaloneCompiler { output_path: &Path, front_matter: &FrontMatter, markdown_body: &str, + imported_prompt_body: &str, skip_integrity: bool, debug_pipeline: bool, ) -> Result { info!("Compiling for standalone target (typed IR)"); let extensions = super::extensions::collect_extensions(front_matter); - let ctx = super::extensions::CompileContext::new(front_matter, input_path).await?; + let mut ctx = super::extensions::CompileContext::new(front_matter, input_path).await?; + ctx.imported_prompt_body = imported_prompt_body.to_string(); let pipeline = super::standalone_ir::build_standalone_pipeline( front_matter, From 6e72496d18f791ff9c3839f9a887087ba892feb4 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 15 Jul 2026 15:25:43 +0100 Subject: [PATCH 06/12] refactor(compile): shorten import-input placeholder to {{ inputs.X }} The `ado.aw.import-inputs.` namespace was a vestigial port of gh-aw's `github.aw.import-inputs.`. gh-aw needs that namespace only to avoid colliding with GitHub Actions' native `${{ inputs.x }}` expression; Azure DevOps has no such construct, and ado-aw already uses the compile-time `{{ }}` delimiter (not ADO's `${{ }}`). So the shorter, cleaner `{{ inputs.X }}` is unambiguous and safe. PLACEHOLDER_PREFIX is now `inputs.`; tests, docs, and the leftover-guard message updated. End-to-end compile re-verified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f --- docs/front-matter.md | 2 +- docs/imports.md | 12 ++--- src/compile/imports/integration_tests.rs | 6 +-- src/compile/imports/schema.rs | 64 ++++++++++++------------ 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/docs/front-matter.md b/docs/front-matter.md index d6a90fe3..eb36a983 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -255,7 +255,7 @@ runtime — write it as clear, structured natural-language instructions. `imports:` lets a workflow reuse local or SHA-pinned cross-repository markdown components. Each imported file is parsed as regular ado-aw markdown with YAML front matter; the compiler validates optional `import-schema:` inputs, applies -`{{ ado.aw.import-inputs. }}` substitutions (a compile-time `{{ ... }}` +`{{ inputs. }}` substitutions (a compile-time `{{ ... }}` replacement — not the ADO `${{ ... }}` template delimiter), then merges the imported front matter and body into the consumer workflow. Imported **body** content is inlined into the agent prompt at compile time (ahead of the diff --git a/docs/imports.md b/docs/imports.md index f1367bd0..8150e426 100644 --- a/docs/imports.md +++ b/docs/imports.md @@ -138,7 +138,7 @@ Current MVP notes: A reusable component can declare non-secret inputs with `import-schema:`. Consumers pass values through `with:`. Values are validated at compile time, defaults are applied, and placeholders of the form -`{{ ado.aw.import-inputs. }}` are substituted throughout the imported front +`{{ inputs. }}` are substituted throughout the imported front matter and body before merge. > **Delimiter.** Import inputs use the compile-time `{{ ... }}` delimiter (the @@ -147,7 +147,7 @@ matter and body before merge. > pipeline YAML and agent prompt, where ADO template-processes any `${{ ... }}` > it finds — so reusing that delimiter would be a footgun. A `{{` immediately > preceded by `$` is treated as an ADO `${{ ... }}` expression and left -> untouched. Any `{{ ado.aw.import-inputs. }}` still present after +> untouched. Any `{{ inputs. }}` still present after > substitution (an input the consumer did not supply and the schema did not > default) is a **compile-time error**. @@ -195,8 +195,8 @@ safe-outputs: env: NOTIFY_TOKEN: TEAM_NOTIFY_TOKEN --- -When notifying the team, use channel `{{ ado.aw.import-inputs.channel }}` and -severity `{{ ado.aw.import-inputs.severity }}`. +When notifying the team, use channel `{{ inputs.channel }}` and +severity `{{ inputs.severity }}`. ``` Consumer: @@ -240,7 +240,7 @@ consumer workflow > later import > earlier import `steps`, `env`, `inputs`, `run`, or `entrypoint`. - Imported markdown bodies are concatenated in declaration order, followed by the consumer body. Imported bodies are **inlined into the agent prompt at - compile time** (their `{{ ado.aw.import-inputs.* }}` placeholders are already + compile time** (their `{{ inputs.* }}` placeholders are already substituted); in the default `inlined-imports: false` mode the consumer's own body is delivered ahead-of-time as a `{{#runtime-import}}` marker so it can still be edited without recompiling, while imported bodies — which can only be @@ -292,7 +292,7 @@ safe-outputs: displayName: Create service ticket --- Use `create-service-ticket` only when a durable service-desk record is needed -for `{{ ado.aw.import-inputs.service }}`. +for `{{ inputs.service }}`. ``` Consumer workflow: diff --git a/src/compile/imports/integration_tests.rs b/src/compile/imports/integration_tests.rs index 443f8e7f..24e14c5a 100644 --- a/src/compile/imports/integration_tests.rs +++ b/src/compile/imports/integration_tests.rs @@ -181,11 +181,11 @@ import-schema: required: true safe-outputs: deploy: - run: "deploy --to {{ ado.aw.import-inputs.destination }}" + run: "deploy --to {{ inputs.destination }}" env: - DESTINATION: "{{ ado.aw.import-inputs.destination }}" + DESTINATION: "{{ inputs.destination }}" --- -Deploy to {{ ado.aw.import-inputs.destination }}. +Deploy to {{ inputs.destination }}. "#, ); let workflow_path = workflow_dir.join("agent.md"); diff --git a/src/compile/imports/schema.rs b/src/compile/imports/schema.rs index 2a1db07a..d025426e 100644 --- a/src/compile/imports/schema.rs +++ b/src/compile/imports/schema.rs @@ -1,5 +1,5 @@ //! `import-schema` modeling, consumer-`with` validation, and -//! `{{ ado.aw.import-inputs. }}` substitution. +//! `{{ inputs. }}` substitution. #![allow(dead_code)] use std::collections::BTreeMap; @@ -9,7 +9,7 @@ use serde_json::{Map as JsonMap, Value as JsonValue}; use serde_yaml::{Mapping as YamlMapping, Value as YamlValue}; const IMPORT_SCHEMA_KEY: &str = "import-schema"; -const PLACEHOLDER_PREFIX: &str = "ado.aw.import-inputs."; +const PLACEHOLDER_PREFIX: &str = "inputs."; #[derive(Debug, Clone, PartialEq)] pub struct ImportSchema { @@ -61,7 +61,7 @@ pub fn validate_with( validate_fields(&schema.fields, with, "") } -/// Substitutes `{{ ado.aw.import-inputs. }}` placeholders in text. +/// Substitutes `{{ inputs. }}` placeholders in text. /// /// This is an ado-aw **compile-time** replacement in the `{{ ... }}` family /// (like `{{ workspace }}`), deliberately NOT the ADO template-expression @@ -118,16 +118,16 @@ pub fn substitute_inputs(text: &str, inputs: &JsonMap) -> Str output } -/// Scans `text` for an **unresolved** `{{ ado.aw.import-inputs. }}` -/// placeholder and returns its expression (e.g. `ado.aw.import-inputs.missing`) +/// Scans `text` for an **unresolved** `{{ inputs. }}` +/// placeholder and returns its expression (e.g. `inputs.missing`) /// if one remains after substitution. /// /// Unlike [`substitute_inputs`], this does NOT skip a `$`-preceded `{{`: an -/// author who mistakenly wrote the old `${{ ado.aw.import-inputs.x }}` form -/// must also be flagged, because ADO would otherwise template-process it in the -/// emitted YAML. Any surviving marker of our namespace is a compile-time error -/// (a body reference to an input not supplied by the consumer `with:` / absent -/// from the component `import-schema:`). +/// author who mistakenly wrote a `$`-delimited `${{ inputs.x }}` form must also +/// be flagged, because ADO would otherwise template-process it in the emitted +/// YAML. Any surviving marker of the `inputs.` namespace is a compile-time +/// error (a body reference to an input not supplied by the consumer `with:` / +/// absent from the component `import-schema:`). fn find_input_placeholder_leak(text: &str) -> Option { let mut cursor = 0; while let Some(relative_start) = text[cursor..].find("{{") { @@ -203,7 +203,7 @@ pub fn apply_import_inputs( let substituted_front_matter = substitute_front_matter(&stripped_front_matter, &inputs); let substituted_body = substitute_inputs(body, &inputs); - // Leftover-placeholder guard: any `{{ ado.aw.import-inputs. }}` still + // Leftover-placeholder guard: any `{{ inputs. }}` still // present after substitution is a reference to an input the consumer did // not supply (via `with:`) and that the component `import-schema:` did not // default. Fail closed — an unresolved marker embedded into the pipeline @@ -707,13 +707,13 @@ import-schema: "config": { "apiKey": "secret" } }); let text = concat!( - "name={{ado.aw.import-inputs.name}} ", - "key={{ ado.aw.import-inputs.config.apiKey }} ", - "count={{ ado.aw.import-inputs.count }} ", - "enabled={{ ado.aw.import-inputs.enabled }} ", - "tags={{ ado.aw.import-inputs.tags }} ", - "config={{ ado.aw.import-inputs.config }} ", - "missing={{ ado.aw.import-inputs.missing }}" + "name={{inputs.name}} ", + "key={{ inputs.config.apiKey }} ", + "count={{ inputs.count }} ", + "enabled={{ inputs.enabled }} ", + "tags={{ inputs.tags }} ", + "config={{ inputs.config }} ", + "missing={{ inputs.missing }}" ); let substituted = substitute_inputs(text, inputs.as_object().unwrap()); @@ -727,7 +727,7 @@ import-schema: "enabled=false ", "tags=[\"a\",\"b\"] ", "config={\"apiKey\":\"secret\"} ", - "missing={{ ado.aw.import-inputs.missing }}" + "missing={{ inputs.missing }}" ) ); } @@ -739,18 +739,18 @@ import-schema: // syntactically mentions our namespace (that leak is caught later by the // guard, not silently substituted here). let inputs = json!({ "name": "demo" }); - let text = "keep ${{ parameters.env }} and {{ ado.aw.import-inputs.name }} and ${{ ado.aw.import-inputs.name }}"; + let text = "keep ${{ parameters.env }} and {{ inputs.name }} and ${{ inputs.name }}"; let substituted = substitute_inputs(text, inputs.as_object().unwrap()); assert_eq!( substituted, - "keep ${{ parameters.env }} and demo and ${{ ado.aw.import-inputs.name }}" + "keep ${{ parameters.env }} and demo and ${{ inputs.name }}" ); } #[test] fn substitute_inputs_preserves_runtime_import_markers() { let inputs = json!({ "name": "demo" }); - let text = "{{#runtime-import agents/x.md}} uses {{ ado.aw.import-inputs.name }}"; + let text = "{{#runtime-import agents/x.md}} uses {{ inputs.name }}"; let substituted = substitute_inputs(text, inputs.as_object().unwrap()); assert_eq!(substituted, "{{#runtime-import agents/x.md}} uses demo"); } @@ -761,7 +761,7 @@ import-schema: // neutralized when interpolated (defense-in-depth), so it cannot smuggle // a `##vso[` pipeline command into a generated step. let inputs = json!({ "evil": "##vso[task.setvariable variable=x]y" }); - let substituted = substitute_inputs("val={{ ado.aw.import-inputs.evil }}", inputs.as_object().unwrap()); + let substituted = substitute_inputs("val={{ inputs.evil }}", inputs.as_object().unwrap()); // The neutralized form wraps the command in backticks so ADO renders it // as inert text instead of executing it. assert!( @@ -778,11 +778,11 @@ import-schema: fn substitute_front_matter_walks_nested_mappings_and_sequences() { let fm = yaml( r#" -name: "{{ ado.aw.import-inputs.name }}" +name: "{{ inputs.name }}" steps: - - bash: echo {{ ado.aw.import-inputs.config.apiKey }} + - bash: echo {{ inputs.config.apiKey }} nested: - value: before {{ado.aw.import-inputs.name}} after + value: before {{inputs.name}} after "#, ); let inputs = json!({ @@ -816,16 +816,16 @@ import-schema: count: type: number default: 2 -name: component-{{ ado.aw.import-inputs.name }} +name: component-{{ inputs.name }} variables: - count: "{{ ado.aw.import-inputs.count }}" + count: "{{ inputs.count }}" "#, ); let with = json!({ "name": "demo" }); let (front_matter, body) = apply_import_inputs( &fm, - "Hello {{ ado.aw.import-inputs.name }} {{ ado.aw.import-inputs.count }}", + "Hello {{ inputs.name }} {{ inputs.count }}", with.as_object().unwrap(), ) .unwrap(); @@ -851,13 +851,13 @@ variables: let with = json!({ "name": "demo" }); let err = apply_import_inputs( &fm, - "Hello {{ ado.aw.import-inputs.typo }}", + "Hello {{ inputs.typo }}", with.as_object().unwrap(), ) .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("unresolved import input placeholder"), "{msg}"); - assert!(msg.contains("ado.aw.import-inputs.typo"), "{msg}"); + assert!(msg.contains("inputs.typo"), "{msg}"); } #[test] @@ -868,7 +868,7 @@ variables: let with = json!({ "name": "demo" }); let err = apply_import_inputs( &fm, - "Hello ${{ ado.aw.import-inputs.name }}", + "Hello ${{ inputs.name }}", with.as_object().unwrap(), ) .unwrap_err(); From 0eec3112db3202c26f254171cf5c828ccf4b99db Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 15 Jul 2026 15:53:52 +0100 Subject: [PATCH 07/12] fix(compile): omit hardcoded refs/heads/main on imported component repos The imported custom-safe-output component repository resource (`custom_repository_resources`) hardcoded `ref: refs/heads/main`. Azure DevOps hard-fails the checkout when the ref does not exist ("Couldn't find remote ref refs/heads/main", no fallback), so any component whose repo default branch is not `main` produced a pipeline that failed at the component-checkout step. The ref is vestigial here: the checkout-component bundle re-fetches the exact pinned SHA at runtime, so the initial shallow checkout only needs a branch that exists. Omitting `ref` makes ADO use the repo's actual default branch. Adds a regression test asserting the resource omits `ref`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f --- src/compile/agentic_pipeline.rs | 53 ++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 83bc85c6..455a04d7 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -1679,7 +1679,15 @@ fn custom_repository_resources(front_matter: &FrontMatter) -> Result` + detached + // checkout), so the initial branch content is irrelevant. + r#ref: None, endpoint: None, }); } @@ -4470,6 +4478,49 @@ mod tests { matches!(env.get(name), Some(EnvValue::Secret(v)) if v == var) } + #[test] + fn custom_component_repo_resource_omits_ref_for_default_branch() { + // Regression: the imported-component repository resource must NOT pin a + // hardcoded `refs/heads/main` ref (ADO hard-fails the checkout for repos + // whose default branch differs). Omitting `ref` makes ADO use the repo's + // actual default branch; the exact SHA is pinned at runtime by the + // checkout-component bundle. + let fm = test_front_matter( + r#" +name: Test +description: Test +safe-outputs: + scripts: + notify-team: + run: node notify.js + component-source: octo/tools/components/notify.md + component-sha: 0123456789012345678901234567890123456789 + manifest-digest: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +"#, + ); + let resources = custom_repository_resources(&fm).unwrap(); + assert_eq!(resources.len(), 1); + match &resources[0] { + RepositoryResource::Named { + identifier, + kind, + name, + r#ref, + endpoint, + } => { + assert!(identifier.starts_with("import_octo_tools_"), "{identifier}"); + assert_eq!(kind, "git"); + assert_eq!(name, "octo/tools"); + assert_eq!( + *r#ref, None, + "ref must be omitted so ADO uses the component repo's default branch" + ); + assert_eq!(*endpoint, None); + } + other => panic!("expected a Named repository resource, got {other:?}"), + } + } + #[test] fn custom_job_scripts_tool_emits_job_config_execute_secret_and_remote_checkout() { let jobs = canonical_jobs_for( From 0c6b1ab7fe94ec0c1851a5d284bb556b72d286e0 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 15 Jul 2026 16:00:10 +0100 Subject: [PATCH 08/12] fix(compile): preserve import cache directory structure to avoid collisions The import manifest cache flattened `/` -> `_` in the filename (`components/deploy.md` -> `components_deploy.md`). That mapping is not injective: `a/b.md` and `a_b.md` from the same repo+SHA collided onto one cache file, silently serving one component's manifest for the other (the digest sidecar records the cached file's own hash, so it could not detect the mismatch), bypassing the SHA-integrity guarantee. Preserve the component's directory structure under the SHA dir instead (`/components/deploy.md`), which is injective and mirrors the source repo. The existing traversal guard (now `validate_import_path_segments`) keeps nested joins safe. Feature is unreleased, so no committed cache needs migrating. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f --- docs/imports.md | 5 ++- src/compile/imports/mod.rs | 91 +++++++++++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/docs/imports.md b/docs/imports.md index 8150e426..d493636b 100644 --- a/docs/imports.md +++ b/docs/imports.md @@ -94,9 +94,12 @@ At compile time, ado-aw fetches the imported **markdown manifest** and stores a SHA-keyed copy under: ```text -.ado-aw/imports////.md +.ado-aw/imports/////.md ``` +The component's directory structure under `/` mirrors its path in the +source repository (e.g. `components/deploy.md`). + The cache is intended to be committed. ado-aw also creates `.ado-aw/imports/.gitattributes` marking cached imports as generated and using `merge=ours`, mirroring gh-aw's committed import-cache model. diff --git a/src/compile/imports/mod.rs b/src/compile/imports/mod.rs index b2223b47..1e2855fb 100644 --- a/src/compile/imports/mod.rs +++ b/src/compile/imports/mod.rs @@ -385,8 +385,8 @@ fn resolve_local_path(base_dir: &Path, import_path: &str) -> Result { } // Reject path-traversal: a `..`/`.` segment (or a backslash) would let a // local import escape the workflow directory and read arbitrary files at - // compile time. Mirrors the guard `flatten_import_path` applies to remote - // import paths. + // compile time. Mirrors the guard `validate_import_path_segments` applies to + // remote import paths. if import_path.contains('\\') || import_path .split('/') @@ -482,14 +482,22 @@ fn enforce_manifest_size(size: usize, source: &str) -> Result<()> { fn cache_path(repo_root: &Path, spec: &ParsedImportSpec) -> Result { validate_cache_segment("owner", &spec.owner)?; validate_cache_segment("repo", &spec.repo)?; - let flat_path = flatten_import_path(&spec.path)?; - Ok(repo_root + let mut path = repo_root .join(".ado-aw") .join("imports") .join(&spec.owner) .join(&spec.repo) - .join(spec.sha.as_str()) - .join(flat_path)) + .join(spec.sha.as_str()); + // Preserve the component's directory structure under the SHA dir (mirrors + // the source repo) rather than flattening `/` -> `_`. Flattening is NOT + // injective (`a/b.md` and `a_b.md` collide onto the same cache file), which + // would silently serve one component's manifest for another from the same + // repo+SHA and bypass the digest sidecar. `validate_import_path_segments` + // enforces the same traversal guard so joining each segment is safe. + for segment in validate_import_path_segments(&spec.path)? { + path.push(segment); + } + Ok(path) } fn validate_cache_segment(label: &str, value: &str) -> Result<()> { @@ -508,16 +516,22 @@ fn validate_cache_segment(label: &str, value: &str) -> Result<()> { Ok(()) } -fn flatten_import_path(path: &str) -> Result { - if path.is_empty() - || path.contains('\\') - || path - .split('/') - .any(|segment| segment.is_empty() || segment == "." || segment == "..") +/// Validate a remote import path and return its `/`-separated segments. +/// +/// Rejects backslashes and any empty / `.` / `..` segment so the returned +/// segments can be joined onto the cache directory without escaping it. +fn validate_import_path_segments(path: &str) -> Result> { + if path.is_empty() || path.contains('\\') { + anyhow::bail!("remote import path contains an invalid segment: `{}`", path); + } + let segments: Vec<&str> = path.split('/').collect(); + if segments + .iter() + .any(|segment| segment.is_empty() || *segment == "." || *segment == "..") { anyhow::bail!("remote import path contains an invalid segment: `{}`", path); } - Ok(path.replace('/', "_")) + Ok(segments) } fn ensure_import_gitattributes(repo_root: &Path) -> Result<()> { @@ -733,7 +747,8 @@ mod tests { .join("acme") .join("shared") .join(SHA) - .join("components_deploy.md"); + .join("components") + .join("deploy.md"); assert!(cache_file.exists()); assert_eq!(fs::read(&cache_file).unwrap(), manifest()); let attributes = fs::read_to_string( @@ -766,8 +781,8 @@ mod tests { .join("acme") .join("shared") .join(SHA); - fs::create_dir_all(&cache_dir).unwrap(); - fs::write(cache_dir.join("components_deploy.md"), manifest()).unwrap(); + fs::create_dir_all(cache_dir.join("components")).unwrap(); + fs::write(cache_dir.join("components").join("deploy.md"), manifest()).unwrap(); let resolved = resolve_imports_with_repo_root(&[entry], repo.path(), repo.path(), &PanicFetcher) @@ -778,6 +793,47 @@ mod tests { assert_eq!(resolved[0].body, "# Imported\nBody"); } + #[tokio::test] + async fn colliding_flattened_paths_get_distinct_cache_files() { + // Regression: `a/b.md` and `a_b.md` from the same repo+SHA previously + // flattened to the same cache file (`a_b.md`), silently serving one + // component's manifest for the other. The preserved directory structure + // gives them distinct cache paths. + let repo = tempfile::tempdir().unwrap(); + let slash = import_entry(&format!("acme/shared/a/b.md@{SHA}")); + let under = import_entry(&format!("acme/shared/a_b.md@{SHA}")); + let fetcher = StaticFetcher { + bytes: manifest().to_vec(), + }; + resolve_imports_with_repo_root( + std::slice::from_ref(&slash), + repo.path(), + repo.path(), + &fetcher, + ) + .await + .unwrap(); + resolve_imports_with_repo_root( + std::slice::from_ref(&under), + repo.path(), + repo.path(), + &fetcher, + ) + .await + .unwrap(); + + let base = repo + .path() + .join(".ado-aw") + .join("imports") + .join("acme") + .join("shared") + .join(SHA); + // `a/b.md` -> nested; `a_b.md` -> flat sibling. Distinct files. + assert!(base.join("a").join("b.md").exists()); + assert!(base.join("a_b.md").exists()); + } + #[tokio::test] async fn size_cap_rejects_large_manifest() { let repo = tempfile::tempdir().unwrap(); @@ -837,7 +893,8 @@ mod tests { .join("acme") .join("shared") .join(SHA) - .join("components_deploy.md"); + .join("components") + .join("deploy.md"); fs::write(&cache_file, b"---\n{}\n---\n# Tampered\nevil\n").unwrap(); let err = From e06d41a3dd718fea1e3adc32aba9db3041cd7197 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 15 Jul 2026 16:42:58 +0100 Subject: [PATCH 09/12] fix(compile): resolve imports in `check` via shared merge helper `check_pipeline` recompiled the source to compare against the committed lock YAML but skipped `imports:` resolution entirely, so any import-using workflow reported false "drift" (the recompile was missing imported front matter and inlined imported bodies), making `ado-aw check` unusable for imports. Extract the import resolve-and-merge logic from `compile_pipeline_inner` into a shared async `resolve_and_merge_imports` helper and call it from both compile and check. `check` now validates the same fully-merged pipeline `compile` produces. It reads the committed SHA-keyed `.ado-aw/imports` cache, so it stays offline when the cache is vendored (the fetcher is only invoked on a cache miss). Adds an end-to-end regression test (compile + check on a local-import workflow). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f --- src/compile/mod.rs | 169 +++++++++++++++++++++++++---------------- tests/codemod_tests.rs | 44 +++++++++++ 2 files changed, 146 insertions(+), 67 deletions(-) diff --git a/src/compile/mod.rs b/src/compile/mod.rs index a7ef73a5..5bab4753 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -159,74 +159,21 @@ async fn compile_pipeline_inner( let body_raw = parsed.body_raw; let source_sha256 = parsed.source_sha256; - // Substituted, joined bodies of any imported components. Inlined into the - // agent prompt at compile time (they cannot be delivered by the default - // runtime-import path, which reads the consumer's own source). Empty when - // the workflow declares no imports. - let mut imported_prompt_body = String::new(); - // Resolve and merge cross-repository / local `imports:` (D8/D9). Runs only // when the workflow declares imports, so import-free workflows are // unaffected. The merge is applied to a CLONE of the front-matter mapping — // the original `front_matter_mapping` is preserved untouched so that any // codemod source-rewrite keeps the author's `imports:` in their file rather // than writing the expanded/merged form back to disk. - if !front_matter.imports.is_empty() { - let base_dir = input_path.parent().unwrap_or_else(|| Path::new(".")); - let repo_root = find_repo_root(base_dir).unwrap_or_else(|| base_dir.to_path_buf()); - - // Build the routing fetcher: endpoint-less / cross-org Azure Repos - // imports resolve via the ADO Git Items API (primary); GitHub/GHE - // imports resolve via `gh`. Azure org URL + auth are resolved - // best-effort here (only when an Azure Repos import is actually - // declared) and any failure is deferred fail-closed to the point the - // affected import is fetched — so a GitHub-only import set, or a - // workflow with no ADO remote, is unaffected. - use crate::compile::types::ImportEndpoint; - let has_same_org_azure = front_matter.imports.iter().any(|e| e.endpoint.is_none()); - let has_any_azure = front_matter.imports.iter().any(|e| { - matches!( - e.endpoint, - None | Some(ImportEndpoint::AzureReposCrossOrg { .. }) - ) - }); - - let ado_fetcher = if has_any_azure { - let context_org_url = if has_same_org_azure { - crate::ado::resolve_ado_context(&repo_root, None, None) - .await - .map(|ctx| ctx.org_url) - .map_err(|e| format!("{e:#}")) - } else { - Err("no same-org Azure Repos import declared".to_string()) - }; - let auth = crate::ado::resolve_auth_non_interactive() - .await - .map_err(|e| format!("{e:#}")); - crate::compile::imports::AdoRepoFetcher::new(context_org_url, auth) - } else { - crate::compile::imports::AdoRepoFetcher::new( - Err("no Azure Repos import declared".to_string()), - Err("no Azure Repos import declared".to_string()), - ) - }; - let fetcher = crate::compile::imports::RoutingFetcher::new(ado_fetcher); - - let mut merged_mapping = front_matter_mapping.clone(); - let (imported, combined) = crate::compile::imports::merge::merge_imports( - &mut merged_mapping, - &markdown_body, - &front_matter.imports, - base_dir, - &repo_root, - &fetcher, - ) - .await?; - imported_prompt_body = imported; - markdown_body = combined; - front_matter = serde_yaml::from_value(serde_yaml::Value::Mapping(merged_mapping)) - .context("Failed to parse front matter after merging imports")?; - } + // + // `imported_prompt_body` is the substituted, joined bodies of any imported + // components, inlined into the agent prompt at compile time (they cannot be + // delivered by the default runtime-import path, which reads the consumer's + // own source). Empty when the workflow declares no imports. + let (imported_prompt_body, merged_body) = + resolve_and_merge_imports(&mut front_matter, &front_matter_mapping, &markdown_body, input_path) + .await?; + markdown_body = merged_body; // Sanitize all front matter text fields before any further processing. // This neutralizes pipeline command injection (##vso[), strips control @@ -685,7 +632,18 @@ pub async fn check_pipeline(pipeline_path: &str) -> Result<()> { } let mut front_matter = parsed.front_matter; - let markdown_body = parsed.markdown_body; + + // Resolve + merge `imports:` so `check` validates the same fully-merged + // pipeline that `compile` produces. Reads the committed `.ado-aw/imports` + // cache (SHA-keyed), so this is offline when the cache is vendored. Uses the + // absolute `source_path` so the repo root (holding the cache) resolves. + let (imported_prompt_body, markdown_body) = resolve_and_merge_imports( + &mut front_matter, + &parsed.front_matter_mapping, + &parsed.markdown_body, + &source_path, + ) + .await?; use crate::sanitize::SanitizeConfig; front_matter.sanitize_config_fields(); @@ -709,10 +667,7 @@ pub async fn check_pipeline(pipeline_path: &str) -> Result<()> { pipeline_path, &front_matter, &markdown_body, - // `check_pipeline` does not resolve `imports:` (pre-existing: it - // also skips the front-matter merge), so there is no imported - // prompt body to inline here. - "", + &imported_prompt_body, false, false, ) @@ -978,6 +933,86 @@ pub fn find_repo_root(start: &Path) -> Option { } } +/// Resolve and merge the workflow's `imports:` into `front_matter` and the body. +/// +/// Shared by [`compile_pipeline_inner`] and [`check_pipeline`] so that `check` +/// validates the *same* fully-merged pipeline that `compile` produces. Mutates +/// `front_matter` in place (imported front matter merged, `imports:` consumed) +/// and returns `(imported_prompt_body, merged_markdown_body)`: +/// - `imported_prompt_body` — the substituted, joined bodies of imported +/// components, inlined into the agent prompt at compile time. +/// - `merged_markdown_body` — imported bodies + the consumer body. +/// +/// A no-op returning `(String::new(), markdown_body)` when there are no imports. +/// +/// `source_path` is the absolute path of the agent source `.md`; its parent is +/// the base directory for local imports and the anchor for locating the repo +/// root (which holds the committed `.ado-aw/imports` cache). +async fn resolve_and_merge_imports( + front_matter: &mut FrontMatter, + front_matter_mapping: &serde_yaml::Mapping, + markdown_body: &str, + source_path: &Path, +) -> Result<(String, String)> { + if front_matter.imports.is_empty() { + return Ok((String::new(), markdown_body.to_string())); + } + + let base_dir = source_path.parent().unwrap_or_else(|| Path::new(".")); + let repo_root = find_repo_root(base_dir).unwrap_or_else(|| base_dir.to_path_buf()); + + // Build the routing fetcher: endpoint-less / cross-org Azure Repos imports + // resolve via the ADO Git Items API (primary); GitHub/GHE imports resolve + // via `gh`. Azure org URL + auth are resolved best-effort here (only when an + // Azure Repos import is actually declared) and any failure is deferred + // fail-closed to the point the affected import is fetched — so a GitHub-only + // import set, a workflow with no ADO remote, or (crucially for `check`) a + // fully-vendored committed cache is unaffected. + use crate::compile::types::ImportEndpoint; + let has_same_org_azure = front_matter.imports.iter().any(|e| e.endpoint.is_none()); + let has_any_azure = front_matter.imports.iter().any(|e| { + matches!( + e.endpoint, + None | Some(ImportEndpoint::AzureReposCrossOrg { .. }) + ) + }); + + let ado_fetcher = if has_any_azure { + let context_org_url = if has_same_org_azure { + crate::ado::resolve_ado_context(&repo_root, None, None) + .await + .map(|ctx| ctx.org_url) + .map_err(|e| format!("{e:#}")) + } else { + Err("no same-org Azure Repos import declared".to_string()) + }; + let auth = crate::ado::resolve_auth_non_interactive() + .await + .map_err(|e| format!("{e:#}")); + crate::compile::imports::AdoRepoFetcher::new(context_org_url, auth) + } else { + crate::compile::imports::AdoRepoFetcher::new( + Err("no Azure Repos import declared".to_string()), + Err("no Azure Repos import declared".to_string()), + ) + }; + let fetcher = crate::compile::imports::RoutingFetcher::new(ado_fetcher); + + let mut merged_mapping = front_matter_mapping.clone(); + let (imported, combined) = crate::compile::imports::merge::merge_imports( + &mut merged_mapping, + markdown_body, + &front_matter.imports, + base_dir, + &repo_root, + &fetcher, + ) + .await?; + *front_matter = serde_yaml::from_value(serde_yaml::Value::Mapping(merged_mapping)) + .context("Failed to parse front matter after merging imports")?; + Ok((imported, combined)) +} + /// Public, read-only entry point that returns the typed [`ir::Pipeline`] /// for an agent source file **without** writing any YAML. /// diff --git a/tests/codemod_tests.rs b/tests/codemod_tests.rs index 813c0124..18e85dbd 100644 --- a/tests/codemod_tests.rs +++ b/tests/codemod_tests.rs @@ -300,6 +300,50 @@ fn test_integrity_check_inlined_imports_true_fails_on_body_edit() { ); } +#[test] +fn test_integrity_check_resolves_imports_and_passes() { + // Regression: `ado-aw check` must resolve `imports:` the same way `compile` + // does. Before the shared resolve-and-merge helper, `check` skipped import + // resolution entirely, so a freshly-compiled import-using workflow reported + // false "drift" (missing imported tools + body). A local import needs no + // cache/network, so this exercises the merge deterministically. + let dir = fresh_git_temp_dir(); + fs::write( + dir.path().join("component.md"), + "---\ntools:\n edit: true\n---\nImported guidance line.\n", + ) + .expect("write component"); + let source = write_source( + dir.path(), + "---\nname: check-imports-agent\ndescription: check resolves imports\nimports:\n - component.md\n---\nConsumer body.\n", + ); + + let compile_output = run_compile(&source); + assert!( + compile_output.status.success(), + "compile should succeed: {}", + String::from_utf8_lossy(&compile_output.stderr) + ); + let lock = source.with_extension("lock.yml"); + assert!(lock.exists(), "expected lock file at {}", lock.display()); + + // The imported tool + inlined imported body must be present in the lock. + let lock_content = fs::read_to_string(&lock).expect("read lock"); + assert!( + lock_content.contains("Imported guidance line."), + "compiled lock should inline the imported body" + ); + + // check must PASS on the freshly compiled, unedited import-using workflow. + let check_output = run_check(&lock); + assert!( + check_output.status.success(), + "check must resolve imports and pass on a fresh compile: stdout={:?} stderr={:?}", + String::from_utf8_lossy(&check_output.stdout), + String::from_utf8_lossy(&check_output.stderr) + ); +} + // ─── Non-mapping front matter ────────────────────────────────────────────── #[test] From 0c623cebc73599c4a3ebc9be00a973a268a6f73a Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 15 Jul 2026 17:13:43 +0100 Subject: [PATCH 10/12] fix(compile): wire remote component provenance + typed endpoint into checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote custom-safe-output component imports never had their provenance stamped onto the merged tool config, so `custom_repository_resources` (which requires `component-source`/`component-sha`) emitted nothing and the component repo was never checked out at runtime — the headline cross-repository custom safe-output feature was inert for real imports, working only when the provenance keys were hand-authored. And even the resource path hardcoded `kind: git` / no endpoint, so GitHub/GHE/cross-org components would be mis-typed. - Stamp `component-source`/`component-sha`/`manifest-digest` plus the resolved `component-repo-type` (git|github|githubenterprise) and `component-endpoint` (service connection) onto each remote import's `safe-outputs.scripts|jobs` tools during merge (local imports, checked out via self, are untouched). - `custom_repository_resources` now reads the stamped repo-type/endpoint instead of hardcoding them, so the executor job checks the component out with the correct resource type and service connection. - Extract the endpoint -> (repo_type, connection) mapping into a shared `endpoint_repo_type_and_connection` reused by the alias synthesizer. Adds merge-stamping tests (remote github/same-org azure, local not stamped) and resource-mapping tests (github/ghe). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f --- src/compile/agentic_pipeline.rs | 90 +++++++++++++++- src/compile/imports/alias.rs | 38 ++++--- src/compile/imports/merge.rs | 180 +++++++++++++++++++++++++++++++- 3 files changed, 291 insertions(+), 17 deletions(-) diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 455a04d7..ce13698d 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -1467,6 +1467,12 @@ struct CustomComponentRuntime { source: String, sha: String, manifest_digest: Option, + /// ADO repository-resource `type` (`git` | `github` | `githubenterprise`), + /// resolved from the import's typed endpoint at merge time. Defaults to + /// `git` (same-org Azure Repos) when unstamped. + repo_type: String, + /// Backing service-connection name, when the endpoint names one. + endpoint: Option, } fn ado_identifier_suffix(raw: &str) -> String { @@ -1649,6 +1655,15 @@ fn parse_custom_component_runtime( .get("manifest-digest") .and_then(serde_json::Value::as_str) .map(str::to_string), + repo_type: tool_obj + .get("component-repo-type") + .and_then(serde_json::Value::as_str) + .unwrap_or("git") + .to_string(), + endpoint: tool_obj + .get("component-endpoint") + .and_then(serde_json::Value::as_str) + .map(str::to_string), }) } @@ -1677,7 +1692,11 @@ fn custom_repository_resources(front_matter: &FrontMatter) -> Result Result` + detached // checkout), so the initial branch content is irrelevant. r#ref: None, - endpoint: None, + endpoint: component.endpoint.clone(), }); } } @@ -4521,6 +4540,73 @@ safe-outputs: } } + #[test] + fn custom_component_repo_resource_maps_typed_endpoint_to_kind_and_connection() { + // A component imported through a GitHub endpoint must produce a + // `github`-typed repository resource wired to its service connection — + // not the hardcoded `git` / no-endpoint. `component-repo-type` and + // `component-endpoint` are stamped from the typed endpoint at merge time. + let fm = test_front_matter( + r#" +name: Test +description: Test +safe-outputs: + scripts: + notify-team: + run: node notify.js + component-source: octo/tools/components/notify.md + component-sha: 0123456789012345678901234567890123456789 + component-repo-type: github + component-endpoint: gh-shared-conn +"#, + ); + let resources = custom_repository_resources(&fm).unwrap(); + assert_eq!(resources.len(), 1); + match &resources[0] { + RepositoryResource::Named { + kind, + name, + r#ref, + endpoint, + .. + } => { + assert_eq!(kind, "github"); + assert_eq!(name, "octo/tools"); + assert_eq!(*r#ref, None); + assert_eq!(endpoint.as_deref(), Some("gh-shared-conn")); + } + other => panic!("expected a Named repository resource, got {other:?}"), + } + } + + #[test] + fn custom_component_repo_resource_ghe_kind_maps_to_githubenterprise() { + let fm = test_front_matter( + r#" +name: Test +description: Test +safe-outputs: + jobs: + ticket: + steps: + - bash: echo hi + component-source: octo/tools/components/ticket.md + component-sha: 0123456789012345678901234567890123456789 + component-repo-type: githubenterprise + component-endpoint: ghe-conn +"#, + ); + let resources = custom_repository_resources(&fm).unwrap(); + assert_eq!(resources.len(), 1); + match &resources[0] { + RepositoryResource::Named { kind, endpoint, .. } => { + assert_eq!(kind, "githubenterprise"); + assert_eq!(endpoint.as_deref(), Some("ghe-conn")); + } + other => panic!("expected a Named repository resource, got {other:?}"), + } + } + #[test] fn custom_job_scripts_tool_emits_job_config_execute_secret_and_remote_checkout() { let jobs = canonical_jobs_for( diff --git a/src/compile/imports/alias.rs b/src/compile/imports/alias.rs index f83533f6..7db753cb 100644 --- a/src/compile/imports/alias.rs +++ b/src/compile/imports/alias.rs @@ -47,6 +47,27 @@ pub fn alias_identifier(owner: &str, repo: &str) -> String { format!("import_{owner}_{repo}_{digest}") } +/// Map a typed import [`ImportEndpoint`] to the ADO repository-resource `type` +/// and the backing service-connection name (`None` for same-org Azure Repos). +/// +/// Single source of truth shared by [`synthesize_repo_aliases`] and the +/// compile-time component-provenance stamping in +/// [`crate::compile::imports::merge`], so the runtime component checkout uses +/// the correct repo type + service connection for GitHub / GitHub Enterprise / +/// cross-org Azure Repos components (not a hardcoded `git` / no-endpoint). +pub(crate) fn endpoint_repo_type_and_connection( + endpoint: Option<&ImportEndpoint>, +) -> (&'static str, Option) { + match endpoint { + None => ("git", None), + Some(ImportEndpoint::AzureReposCrossOrg { name, .. }) => ("git", Some(name.clone())), + Some(ImportEndpoint::GitHub { name }) => ("github", Some(name.clone())), + Some(ImportEndpoint::GitHubEnterprise { name, .. }) => { + ("githubenterprise", Some(name.clone())) + } + } +} + /// Synthesize ADO repository resources for remote imports. /// /// Local imports are compile-time only and do not need a runtime checkout, so @@ -101,20 +122,9 @@ pub fn synthesize_repo_aliases(imports: &[ResolvedImport]) -> Result ("git", None), - Some(ImportEndpoint::AzureReposCrossOrg { name, .. }) => { - ("git", Some(name.clone())) - } - Some(ImportEndpoint::GitHub { name }) => ("github", Some(name.clone())), - Some(ImportEndpoint::GitHubEnterprise { name, .. }) => { - ("githubenterprise", Some(name.clone())) - } - }; + // the backing service-connection `name` via the shared mapping. + let (repo_type, endpoint_name) = + endpoint_repo_type_and_connection(key.endpoint.as_ref()); Repository { repository: alias, diff --git a/src/compile/imports/merge.rs b/src/compile/imports/merge.rs index a6bc7cfe..e85d8c1a 100644 --- a/src/compile/imports/merge.rs +++ b/src/compile/imports/merge.rs @@ -109,7 +109,7 @@ pub fn merge_resolved_imported_body( let mut body_parts: Vec = Vec::new(); for (idx, import) in resolved.iter().enumerate() { - let (sub_fm, sub_body) = + let (mut sub_fm, sub_body) = apply_import_inputs(&import.front_matter, &import.body, &import.entry.with) .with_context(|| { format!( @@ -118,6 +118,13 @@ pub fn merge_resolved_imported_body( ) })?; + // Stamp compile-time component provenance (source / sha / manifest + // digest + resolved repo-type/service-connection from the typed + // endpoint) onto this import's custom safe-output tools, so the runtime + // executor job can check the component repo out at the pinned SHA. Only + // remote imports have a repo to check out. + stamp_component_provenance(&mut sub_fm, import); + if let Value::Mapping(component_map) = &sub_fm { merge_import_into_acc( &mut acc, @@ -145,6 +152,72 @@ pub fn merge_resolved_imported_body( Ok(body_parts.join("\n\n")) } +/// Stamp compiler-owned component provenance onto the custom safe-output tools +/// (`safe-outputs.scripts.*` / `safe-outputs.jobs.*`) declared by a **remote** +/// import, so the runtime executor job can check the component repository out at +/// the pinned commit. +/// +/// For each such tool, inserts `component-source` (owner/repo/path), +/// `component-sha`, `manifest-digest`, `component-repo-type` +/// (`git` | `github` | `githubenterprise`), and — when the endpoint names a +/// service connection — `component-endpoint`. Local imports (whose components +/// live in the consumer repo's own checkout) are left untouched. +fn stamp_component_provenance(component_fm: &mut Value, import: &ResolvedImport) { + use crate::compile::types::ImportSource; + + // Only remote imports have a separate repository to check out. + if !matches!(import.source, ImportSource::Remote(_)) { + return; + } + let Some(sha) = import.provenance.sha.as_deref() else { + return; + }; + let (repo_type, endpoint_name) = + crate::compile::imports::alias::endpoint_repo_type_and_connection( + import.entry.endpoint.as_ref(), + ); + + let Value::Mapping(fm_map) = component_fm else { + return; + }; + let Some(Value::Mapping(safe_outputs)) = fm_map.get_mut("safe-outputs") else { + return; + }; + + for section in ["scripts", "jobs"] { + let Some(Value::Mapping(tools)) = safe_outputs.get_mut(section) else { + continue; + }; + for (_tool_name, tool_cfg) in tools.iter_mut() { + let Value::Mapping(cfg) = tool_cfg else { + continue; + }; + cfg.insert( + Value::String("component-source".into()), + Value::String(import.provenance.source.clone()), + ); + cfg.insert( + Value::String("component-sha".into()), + Value::String(sha.to_string()), + ); + cfg.insert( + Value::String("manifest-digest".into()), + Value::String(import.provenance.manifest_digest.clone()), + ); + cfg.insert( + Value::String("component-repo-type".into()), + Value::String(repo_type.to_string()), + ); + if let Some(name) = &endpoint_name { + cfg.insert( + Value::String("component-endpoint".into()), + Value::String(name.clone()), + ); + } + } + } +} + /// Merge one import's mapping into the accumulator, enforcing import-vs-import /// collision rules. fn merge_import_into_acc( @@ -385,6 +458,111 @@ mod tests { } } + const REMOTE_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + + fn remote_resolved( + fm_yaml: &str, + endpoint: Option, + ) -> ResolvedImport { + use crate::compile::types::ParsedImportSpec; + use crate::secure::CommitSha; + ResolvedImport { + entry: ImportEntry { + uses: format!("octo/repo/notify.md@{REMOTE_SHA}"), + with: serde_json::Map::new(), + endpoint: endpoint.clone(), + }, + source: ImportSource::Remote(ParsedImportSpec { + owner: "octo".to_string(), + repo: "repo".to_string(), + path: "notify.md".to_string(), + sha: CommitSha::parse(REMOTE_SHA).unwrap(), + section: None, + optional: false, + endpoint, + }), + front_matter: serde_yaml::from_str(fm_yaml).unwrap(), + body: String::new(), + provenance: ImportProvenance { + source: "octo/repo/notify.md".to_string(), + sha: Some(REMOTE_SHA.to_string()), + manifest_digest: "digest123".to_string(), + }, + } + } + + fn scripts_notify_tool(consumer: &Mapping) -> &Mapping { + consumer + .get(Value::String("safe-outputs".into())) + .and_then(Value::as_mapping) + .and_then(|so| so.get(Value::String("scripts".into()))) + .and_then(Value::as_mapping) + .and_then(|s| s.get(Value::String("notify".into()))) + .and_then(Value::as_mapping) + .expect("safe-outputs.scripts.notify present") + } + + fn tool_str<'a>(tool: &'a Mapping, key: &str) -> Option<&'a str> { + tool.get(Value::String(key.into())).and_then(Value::as_str) + } + + #[test] + fn remote_component_gets_provenance_and_endpoint_stamped() { + use crate::compile::types::ImportEndpoint; + let mut consumer = ymap("name: consumer"); + let import = remote_resolved( + "safe-outputs:\n scripts:\n notify:\n run: node n.js\n", + Some(ImportEndpoint::GitHub { + name: "gh-conn".to_string(), + }), + ); + merge_resolved_imported_body(&mut consumer, &[import]).unwrap(); + + let notify = scripts_notify_tool(&consumer); + assert_eq!(tool_str(notify, "component-source"), Some("octo/repo/notify.md")); + assert_eq!(tool_str(notify, "component-sha"), Some(REMOTE_SHA)); + assert_eq!(tool_str(notify, "manifest-digest"), Some("digest123")); + assert_eq!(tool_str(notify, "component-repo-type"), Some("github")); + assert_eq!(tool_str(notify, "component-endpoint"), Some("gh-conn")); + } + + #[test] + fn remote_same_org_azure_component_stamps_git_without_endpoint() { + let mut consumer = ymap("name: consumer"); + // Endpoint-less remote import => same-org Azure Repos (`git`, no conn). + let import = remote_resolved( + "safe-outputs:\n jobs:\n notify:\n steps:\n - bash: echo hi\n", + None, + ); + merge_resolved_imported_body(&mut consumer, &[import]).unwrap(); + + let notify = consumer + .get(Value::String("safe-outputs".into())) + .and_then(Value::as_mapping) + .and_then(|so| so.get(Value::String("jobs".into()))) + .and_then(Value::as_mapping) + .and_then(|j| j.get(Value::String("notify".into()))) + .and_then(Value::as_mapping) + .expect("safe-outputs.jobs.notify present"); + assert_eq!(tool_str(notify, "component-repo-type"), Some("git")); + assert_eq!(tool_str(notify, "component-endpoint"), None); + assert_eq!(tool_str(notify, "component-sha"), Some(REMOTE_SHA)); + } + + #[test] + fn local_component_is_not_stamped() { + let mut consumer = ymap("name: consumer"); + let import = resolved( + "safe-outputs:\n scripts:\n notify:\n run: node n.js\n", + "", + ); + merge_resolved_imported_body(&mut consumer, &[import]).unwrap(); + + let notify = scripts_notify_tool(&consumer); + assert_eq!(tool_str(notify, "component-source"), None); + assert_eq!(tool_str(notify, "component-repo-type"), None); + } + #[test] fn consumer_wins_for_scalars() { let mut consumer = ymap("engine: copilot\nname: consumer"); From 1ca9ec68245369cd25c5a23f8f576a61a027bb07 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 15 Jul 2026 21:23:49 +0100 Subject: [PATCH 11/12] fix(compile): fully own component provenance keys to prevent spoofing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review found that stamp_component_provenance only conditionally inserted `component-endpoint` (when the import resolved a service connection). For a same-org (endpoint-less) remote import the connection is None, so a `component-endpoint` value authored into the component's own front matter survived the merge and was used as the checkout repository-resource endpoint — letting a component inject an arbitrary service connection onto its own checkout, violating the "compiler owns provenance" invariant. Harden: strip ALL compiler-owned `component-*` keys (source/sha/manifest-digest/ repo-type/endpoint) from every imported component (local and remote) before stamping, then stamp compiler-resolved values for remote imports only. This also removes the local-import footgun where a preset component-source/sha would synthesize a spurious checkout resource. Adds spoofing-guard tests. Also correct the now-stale CompileContext.imported_prompt_body doc (check does resolve imports; the unresolved path is build_pipeline_ir for inspect/graph). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f --- src/compile/extensions/mod.rs | 3 +- src/compile/imports/merge.rs | 143 +++++++++++++++++++++++++--------- 2 files changed, 107 insertions(+), 39 deletions(-) diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 32beb2aa..db137dd5 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -116,7 +116,8 @@ pub struct CompileContext<'a> { pub input_path: Option<&'a Path>, /// Substituted, joined bodies of any imported components, inlined into the /// agent prompt at compile time. Empty when the workflow declares no - /// imports (or on paths that do not resolve imports, e.g. `check`). + /// imports (or on paths that do not resolve imports, e.g. `build_pipeline_ir` + /// for `inspect`/`graph`). Both `compile` and `check` resolve imports. pub imported_prompt_body: String, } diff --git a/src/compile/imports/merge.rs b/src/compile/imports/merge.rs index e85d8c1a..9527000e 100644 --- a/src/compile/imports/merge.rs +++ b/src/compile/imports/merge.rs @@ -153,29 +153,31 @@ pub fn merge_resolved_imported_body( } /// Stamp compiler-owned component provenance onto the custom safe-output tools -/// (`safe-outputs.scripts.*` / `safe-outputs.jobs.*`) declared by a **remote** -/// import, so the runtime executor job can check the component repository out at -/// the pinned commit. +/// (`safe-outputs.scripts.*` / `safe-outputs.jobs.*`) declared by an import, so +/// the runtime executor job can check the component repository out at the pinned +/// commit — while ensuring the compiler **fully owns** these keys. /// -/// For each such tool, inserts `component-source` (owner/repo/path), -/// `component-sha`, `manifest-digest`, `component-repo-type` -/// (`git` | `github` | `githubenterprise`), and — when the endpoint names a -/// service connection — `component-endpoint`. Local imports (whose components -/// live in the consumer repo's own checkout) are left untouched. +/// The `component-*` keys are compiler-owned provenance. For **every** imported +/// component (local or remote) any author-provided `component-*` value is first +/// **stripped**, so a component cannot spoof its own checkout (e.g. inject a +/// `component-endpoint` service connection or redirect `component-source`). +/// Then, for **remote** imports only, the compiler-resolved values are stamped: +/// `component-source` (owner/repo/path), `component-sha`, `manifest-digest`, +/// `component-repo-type` (`git` | `github` | `githubenterprise`), and — when the +/// endpoint names a service connection — `component-endpoint`. Local imports +/// (whose components live in the consumer repo's own checkout) end up with no +/// provenance keys and thus synthesize no separate checkout resource. fn stamp_component_provenance(component_fm: &mut Value, import: &ResolvedImport) { use crate::compile::types::ImportSource; - // Only remote imports have a separate repository to check out. - if !matches!(import.source, ImportSource::Remote(_)) { - return; - } - let Some(sha) = import.provenance.sha.as_deref() else { - return; - }; - let (repo_type, endpoint_name) = - crate::compile::imports::alias::endpoint_repo_type_and_connection( - import.entry.endpoint.as_ref(), - ); + /// Compiler-owned provenance keys — never author-settable. + const PROVENANCE_KEYS: [&str; 5] = [ + "component-source", + "component-sha", + "manifest-digest", + "component-repo-type", + "component-endpoint", + ]; let Value::Mapping(fm_map) = component_fm else { return; @@ -184,6 +186,17 @@ fn stamp_component_provenance(component_fm: &mut Value, import: &ResolvedImport) return; }; + // Remote imports carry a repo + pinned SHA to check out; local imports do + // not (their components live in the consumer's own checkout). + let remote_sha = match &import.source { + ImportSource::Remote(_) => import.provenance.sha.as_deref(), + _ => None, + }; + let (repo_type, endpoint_name) = + crate::compile::imports::alias::endpoint_repo_type_and_connection( + import.entry.endpoint.as_ref(), + ); + for section in ["scripts", "jobs"] { let Some(Value::Mapping(tools)) = safe_outputs.get_mut(section) else { continue; @@ -192,27 +205,37 @@ fn stamp_component_provenance(component_fm: &mut Value, import: &ResolvedImport) let Value::Mapping(cfg) = tool_cfg else { continue; }; - cfg.insert( - Value::String("component-source".into()), - Value::String(import.provenance.source.clone()), - ); - cfg.insert( - Value::String("component-sha".into()), - Value::String(sha.to_string()), - ); - cfg.insert( - Value::String("manifest-digest".into()), - Value::String(import.provenance.manifest_digest.clone()), - ); - cfg.insert( - Value::String("component-repo-type".into()), - Value::String(repo_type.to_string()), - ); - if let Some(name) = &endpoint_name { + + // Strip any author-provided provenance first (compiler fully owns + // these keys). + for key in PROVENANCE_KEYS { + cfg.remove(Value::String(key.to_string())); + } + + // Stamp compiler-resolved provenance for remote imports only. + if let Some(sha) = remote_sha { cfg.insert( - Value::String("component-endpoint".into()), - Value::String(name.clone()), + Value::String("component-source".into()), + Value::String(import.provenance.source.clone()), ); + cfg.insert( + Value::String("component-sha".into()), + Value::String(sha.to_string()), + ); + cfg.insert( + Value::String("manifest-digest".into()), + Value::String(import.provenance.manifest_digest.clone()), + ); + cfg.insert( + Value::String("component-repo-type".into()), + Value::String(repo_type.to_string()), + ); + if let Some(name) = &endpoint_name { + cfg.insert( + Value::String("component-endpoint".into()), + Value::String(name.clone()), + ); + } } } } @@ -563,6 +586,50 @@ mod tests { assert_eq!(tool_str(notify, "component-repo-type"), None); } + #[test] + fn component_cannot_spoof_provenance_keys() { + use crate::compile::types::ImportEndpoint; + // A component authoring compiler-owned provenance keys into its own + // front matter must NOT influence the checkout. For a same-org + // (endpoint-less) remote import the compiler resolves no service + // connection, so a pre-set `component-endpoint` must be stripped + // (compiler fully owns the key). A spoofed `component-source` must be + // overwritten with the real source. + let mut consumer = ymap("name: consumer"); + let import = remote_resolved( + "safe-outputs:\n scripts:\n notify:\n run: node n.js\n \ + component-endpoint: attacker-conn\n component-source: evil/repo/x.md\n", + None, + ); + merge_resolved_imported_body(&mut consumer, &[import]).unwrap(); + + let notify = scripts_notify_tool(&consumer); + // Spoofed endpoint stripped (same-org => no connection). + assert_eq!(tool_str(notify, "component-endpoint"), None); + // Spoofed source overwritten with the compiler-resolved provenance. + assert_eq!(tool_str(notify, "component-source"), Some("octo/repo/notify.md")); + assert_eq!(tool_str(notify, "component-repo-type"), Some("git")); + } + + #[test] + fn remote_endpoint_overwrites_author_provided_component_endpoint() { + use crate::compile::types::ImportEndpoint; + // When the import DOES resolve a connection, the compiler value wins + // over any author-provided one. + let mut consumer = ymap("name: consumer"); + let import = remote_resolved( + "safe-outputs:\n scripts:\n notify:\n run: node n.js\n \ + component-endpoint: attacker-conn\n", + Some(ImportEndpoint::GitHub { + name: "real-conn".to_string(), + }), + ); + merge_resolved_imported_body(&mut consumer, &[import]).unwrap(); + + let notify = scripts_notify_tool(&consumer); + assert_eq!(tool_str(notify, "component-endpoint"), Some("real-conn")); + } + #[test] fn consumer_wins_for_scalars() { let mut consumer = ymap("engine: copilot\nname: consumer"); From b9dca14e68b7bf3007d67d1413ceac61c16c3ead Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 15 Jul 2026 21:36:53 +0100 Subject: [PATCH 12/12] fix(compile): resolve imports in inspect/graph + make check fully offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review follow-ups: A. `build_pipeline_ir` (powers `inspect`/`graph`/`whatif`/`lint`/`trace`) now calls the shared `resolve_and_merge_imports`, so those commands reason about the same fully-merged pipeline `compile`/`check` produce (imported tools, safe-outputs, custom jobs, inlined bodies) instead of the pre-merge front matter. Adds an inspect-with-imports regression test. B. `AdoRepoFetcher` now resolves its consumer org URL + non-interactive auth LAZILY on the first actual fetch (cached via `tokio::sync::OnceCell`) instead of eagerly at construction. The import cache is consulted before any fetch, so a fully-vendored committed cache — as used by `check`/`inspect` — performs no `git`/`az` subprocess or network work. Fail-closed behaviour is preserved (GitHub/GHE spec rejected before any resolution; unresolved org/auth errors only on an actual uncached Azure fetch). Test-only `with_resolved` constructor keeps the fetcher unit tests subprocess-free. `ManifestFetcher` gains a `Send + Sync` supertrait bound so a `&dyn ManifestFetcher` can be held across an await in the `Send` future the `mcp-author` tool router requires (surfaced by A). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f --- src/compile/imports/merge.rs | 1 - src/compile/imports/mod.rs | 100 +++++++++++++++++++++++++---------- src/compile/mod.rs | 56 ++++++++------------ tests/inspect_integration.rs | 36 +++++++++++++ 4 files changed, 130 insertions(+), 63 deletions(-) diff --git a/src/compile/imports/merge.rs b/src/compile/imports/merge.rs index 9527000e..2b853a13 100644 --- a/src/compile/imports/merge.rs +++ b/src/compile/imports/merge.rs @@ -588,7 +588,6 @@ mod tests { #[test] fn component_cannot_spoof_provenance_keys() { - use crate::compile::types::ImportEndpoint; // A component authoring compiler-owned provenance keys into its own // front matter must NOT influence the checkout. For a same-org // (endpoint-less) remote import the compiler resolves no service diff --git a/src/compile/imports/mod.rs b/src/compile/imports/mod.rs index 1e2855fb..3fdcf698 100644 --- a/src/compile/imports/mod.rs +++ b/src/compile/imports/mod.rs @@ -30,8 +30,12 @@ const IMPORT_GITATTRIBUTES: &str = "# Mark all cached import files as generated\ * merge=ours\n"; /// Fetches a single SHA-pinned component manifest. +/// +/// `Send + Sync` so a `&dyn ManifestFetcher` can be held across an await in a +/// `Send` future (e.g. `build_pipeline_ir`, which the `mcp-author` tool router +/// spawns on a multi-threaded runtime). #[async_trait] -pub trait ManifestFetcher { +pub trait ManifestFetcher: Send + Sync { async fn fetch(&self, spec: &ParsedImportSpec) -> Result>; } @@ -109,44 +113,93 @@ impl ManifestFetcher for GhCliFetcher { /// Azure Repos-backed manifest fetcher — the **primary** compile-time source. /// /// Fetches a SHA-pinned manifest from the ADO Git Items API. Endpoint-less -/// imports resolve against the consumer's own organization (`context_org_url`); +/// imports resolve against the consumer's own organization (from `repo_root`); /// [`ImportEndpoint::AzureReposCrossOrg`] imports resolve against the /// organization named in the endpoint. The import spec's `owner` maps to the /// ADO **project** and `repo` to the repository name. /// -/// `context_org_url` and `auth` are resolved once, best-effort, at construction -/// time (only when the workflow actually declares an Azure Repos import). A -/// failure to resolve either is deferred and surfaced **fail-closed** when an -/// Azure Repos import is actually fetched — an Azure-Repos-typed import never +/// The consumer org URL and non-interactive auth are resolved **lazily on the +/// first actual fetch** (and cached), so a fully-vendored committed cache — as +/// used by `ado-aw check` — performs no `git`/`az` subprocess or network work +/// (the cache is consulted before `fetch` is ever called). A resolution failure +/// is surfaced **fail-closed** at fetch time; an Azure-Repos-typed import never /// silently falls back to GitHub. pub struct AdoRepoFetcher { client: reqwest::Client, - /// Consumer organization collection URL for same-org imports, or the reason - /// it could not be resolved. - context_org_url: std::result::Result, - /// Non-interactive ADO auth, or the reason it could not be resolved. - auth: std::result::Result, + /// Repo root used to infer the consumer org for same-org imports. + repo_root: PathBuf, + /// Lazily-resolved consumer organization collection URL (same-org imports). + context_org_url: tokio::sync::OnceCell>, + /// Lazily-resolved non-interactive ADO auth. + auth: tokio::sync::OnceCell>, } impl AdoRepoFetcher { - /// Construct a fetcher from a best-effort org URL and auth resolution. - pub fn new( + /// Construct a fetcher that resolves org/auth lazily on first fetch, using + /// `repo_root` to infer the consumer organization for same-org imports. + pub fn new(repo_root: PathBuf) -> Self { + Self { + client: reqwest::Client::new(), + repo_root, + context_org_url: tokio::sync::OnceCell::new(), + auth: tokio::sync::OnceCell::new(), + } + } + + /// Test constructor with the org URL + auth pre-resolved (no subprocess). + #[cfg(test)] + pub fn with_resolved( context_org_url: std::result::Result, auth: std::result::Result, ) -> Self { Self { client: reqwest::Client::new(), - context_org_url, - auth, + repo_root: PathBuf::new(), + context_org_url: tokio::sync::OnceCell::new_with(Some(context_org_url)), + auth: tokio::sync::OnceCell::new_with(Some(auth)), } } + + /// Consumer org URL for same-org imports, resolved + cached on first use. + async fn context_org_url(&self) -> &std::result::Result { + self.context_org_url + .get_or_init(|| async { + crate::ado::resolve_ado_context(&self.repo_root, None, None) + .await + .map(|ctx| ctx.org_url) + .map_err(|e| format!("{e:#}")) + }) + .await + } + + /// Non-interactive ADO auth, resolved + cached on first use. + async fn auth(&self) -> &std::result::Result { + self.auth + .get_or_init(|| async { + crate::ado::resolve_auth_non_interactive() + .await + .map_err(|e| format!("{e:#}")) + }) + .await + } } #[async_trait] impl ManifestFetcher for AdoRepoFetcher { async fn fetch(&self, spec: &ParsedImportSpec) -> Result> { + // Fail-closed BEFORE any lazy org/auth resolution: a GitHub/GHE-typed + // import must never reach the Azure Repos fetcher. + if let Some(other @ (ImportEndpoint::GitHub { .. } | ImportEndpoint::GitHubEnterprise { .. })) = + &spec.endpoint + { + anyhow::bail!( + "internal routing error: Azure Repos fetcher received a {:?} import", + other + ); + } + let org_url = match &spec.endpoint { - None => self.context_org_url.as_deref().map_err(|reason| { + None => self.context_org_url().await.as_deref().map_err(|reason| { anyhow::anyhow!( "cannot fetch same-org Azure Repos import `{}/{}/{}`: {}. \ Set AZURE_DEVOPS_ORG_URL / SYSTEM_COLLECTIONURI or run from an \ @@ -158,17 +211,10 @@ impl ManifestFetcher for AdoRepoFetcher { ) })?, Some(ImportEndpoint::AzureReposCrossOrg { org, .. }) => org.as_str(), - Some(other) => { - // Fail-closed: a GitHub/GHE-typed import must never reach the - // Azure Repos fetcher. - anyhow::bail!( - "internal routing error: Azure Repos fetcher received a {:?} import", - other - ); - } + Some(_) => unreachable!("github/ghe rejected above"), }; - let auth = self.auth.as_ref().map_err(|reason| { + let auth = self.auth().await.as_ref().map_err(|reason| { anyhow::anyhow!( "cannot authenticate to Azure Repos for import `{}/{}/{}`: {}", spec.owner, @@ -1044,7 +1090,7 @@ mod tests { /// fall through to GitHub. Regression guard for the source-confusion bug. #[tokio::test] async fn ado_fetcher_endpoint_less_without_org_fails_closed() { - let fetcher = AdoRepoFetcher::new( + let fetcher = AdoRepoFetcher::with_resolved( Err("no ADO remote".to_string()), Err("no creds".to_string()), ); @@ -1062,7 +1108,7 @@ mod tests { /// A GitHub-typed spec must never be accepted by the Azure Repos fetcher. #[tokio::test] async fn ado_fetcher_rejects_github_typed_spec() { - let fetcher = AdoRepoFetcher::new( + let fetcher = AdoRepoFetcher::with_resolved( Ok("https://dev.azure.com/org".to_string()), Ok(crate::ado::AdoAuth::Pat("x".to_string())), ); diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 5bab4753..c9597ef8 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -963,39 +963,13 @@ async fn resolve_and_merge_imports( // Build the routing fetcher: endpoint-less / cross-org Azure Repos imports // resolve via the ADO Git Items API (primary); GitHub/GHE imports resolve - // via `gh`. Azure org URL + auth are resolved best-effort here (only when an - // Azure Repos import is actually declared) and any failure is deferred - // fail-closed to the point the affected import is fetched — so a GitHub-only - // import set, a workflow with no ADO remote, or (crucially for `check`) a - // fully-vendored committed cache is unaffected. - use crate::compile::types::ImportEndpoint; - let has_same_org_azure = front_matter.imports.iter().any(|e| e.endpoint.is_none()); - let has_any_azure = front_matter.imports.iter().any(|e| { - matches!( - e.endpoint, - None | Some(ImportEndpoint::AzureReposCrossOrg { .. }) - ) - }); - - let ado_fetcher = if has_any_azure { - let context_org_url = if has_same_org_azure { - crate::ado::resolve_ado_context(&repo_root, None, None) - .await - .map(|ctx| ctx.org_url) - .map_err(|e| format!("{e:#}")) - } else { - Err("no same-org Azure Repos import declared".to_string()) - }; - let auth = crate::ado::resolve_auth_non_interactive() - .await - .map_err(|e| format!("{e:#}")); - crate::compile::imports::AdoRepoFetcher::new(context_org_url, auth) - } else { - crate::compile::imports::AdoRepoFetcher::new( - Err("no Azure Repos import declared".to_string()), - Err("no Azure Repos import declared".to_string()), - ) - }; + // via `gh`. The Azure fetcher resolves its org URL + auth LAZILY on the + // first actual fetch (see `AdoRepoFetcher`), so a GitHub-only import set, a + // workflow with no ADO remote, or — crucially for `check`/`inspect` — a + // fully-vendored committed cache performs no `git`/`az` work at all (the + // cache is consulted before any fetch), and any resolution failure surfaces + // fail-closed only when an uncached Azure import is actually fetched. + let ado_fetcher = crate::compile::imports::AdoRepoFetcher::new(repo_root.clone()); let fetcher = crate::compile::imports::RoutingFetcher::new(ado_fetcher); let mut merged_mapping = front_matter_mapping.clone(); @@ -1035,7 +1009,18 @@ pub async fn build_pipeline_ir(input_path: &Path) -> Result<(FrontMatter, ir::Pi let parsed = common::parse_markdown_detailed(&content)?; let mut front_matter = parsed.front_matter; - let markdown_body = parsed.markdown_body; + + // Resolve + merge `imports:` so `inspect`/`graph`/`whatif`/`lint`/`trace` + // reason about the same fully-merged pipeline `compile` and `check` produce + // (imported tools, safe-outputs, custom jobs, and inlined bodies). Reads the + // vendored SHA-keyed cache, so it stays offline when the cache is present. + let (imported_prompt_body, markdown_body) = resolve_and_merge_imports( + &mut front_matter, + &parsed.front_matter_mapping, + &parsed.markdown_body, + input_path, + ) + .await?; use crate::sanitize::SanitizeConfig; front_matter.sanitize_config_fields(); @@ -1053,7 +1038,8 @@ pub async fn build_pipeline_ir(input_path: &Path) -> Result<(FrontMatter, ir::Pi let output_path = input_path.with_extension("lock.yml"); let extensions = extensions::collect_extensions(&front_matter); - let ctx = extensions::CompileContext::new(&front_matter, input_path).await?; + let mut ctx = extensions::CompileContext::new(&front_matter, input_path).await?; + ctx.imported_prompt_body = imported_prompt_body; let pipeline = match front_matter.target { CompileTarget::Standalone => standalone_ir::build_standalone_pipeline( diff --git a/tests/inspect_integration.rs b/tests/inspect_integration.rs index 42d6614c..b26c47b4 100644 --- a/tests/inspect_integration.rs +++ b/tests/inspect_integration.rs @@ -83,6 +83,42 @@ fn inspect_json_emits_schema_version_one() { ); } +#[test] +fn inspect_resolves_imports_and_shows_imported_custom_job() { + // Regression for Fix A: `build_pipeline_ir` (which powers `inspect`/`graph`) + // must resolve `imports:` so it reasons about the merged pipeline. A local + // import of a `safe-outputs.scripts` component contributes a `Custom_` + // job that only appears if imports are resolved. + let workspace = tempfile::tempdir().expect("create temp dir"); + std::fs::write( + workspace.path().join("component.md"), + "---\nsafe-outputs:\n scripts:\n notify-team:\n run: node notify.js\n---\nComponent body.\n", + ) + .expect("write component"); + let consumer = workspace.path().join("agent.md"); + std::fs::write( + &consumer, + "---\nname: importer\ndescription: imports a component\nimports:\n - component.md\n---\nConsumer body.\n", + ) + .expect("write consumer"); + + let out = Command::new(binary_path()) + .arg("inspect") + .arg(&consumer) + .output() + .expect("run ado-aw inspect"); + assert!( + out.status.success(), + "inspect exited non-zero. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("Custom_notify_team"), + "inspect must resolve imports and show the imported custom job, got:\n{stdout}" + ); +} + #[test] fn graph_dot_emits_digraph_with_known_edges() { let (_workspace, src) = fixture_copy("canary.md");