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..eb36a983 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,61 @@ 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 +`{{ 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: + - ./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 +465,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..d493636b --- /dev/null +++ b/docs/imports.md @@ -0,0 +1,319 @@ +# 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: + # Same-org Azure Repos (the primary, default source — no endpoint): + - myproject/shared-agents/components/notify.md@0123456789abcdef0123456789abcdef01234567 + # Local import: + - ./components/local-guidance.md + # GitHub.com, via an ADO service connection (bare-string endpoint shorthand): + - 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?`. + +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 + +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 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. + +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. + +### 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: + +- 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 +`{{ 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 `{{ 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. +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 `{{ inputs.channel }}` and +severity `{{ 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. Imported bodies are **inlined into the agent prompt at + 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 + substituted at compile time — precede it inline. +- `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 `{{ 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/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/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/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/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 06b20342..ce13698d 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- @@ -301,13 +302,18 @@ pub(crate) fn build_pipeline_context( front_matter, input_path, markdown_body, + &ctx.imported_prompt_body, &source_path, &trigger_repo_directory, )?; // ─── 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 +434,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 +501,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 +531,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 +687,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 +702,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 +730,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 +979,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 +1329,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 +1414,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 +1442,587 @@ 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, + /// 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 { + 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), + 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), + }) +} + +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, + // Resolved from the import's typed endpoint at merge time: + // `git` for (same- or cross-org) Azure Repos, `github` / + // `githubenterprise` for GitHub sources. The service connection + // (when any) rides on `endpoint`. + kind: component.repo_type.clone(), + name: format!("{owner}/{repo}"), + // Omit `ref` so ADO checks out the component repo's actual + // default branch (which always exists) for the initial shallow + // checkout. A hardcoded `refs/heads/main` hard-fails the + // checkout step for any repo whose default branch is not `main` + // (ADO: "Couldn't find remote ref …", no fallback). The exact + // import commit is pinned separately at runtime by the + // checkout-component bundle (`git fetch ` + detached + // checkout), so the initial branch content is irrelevant. + r#ref: None, + endpoint: component.endpoint.clone(), + }); + } + } + 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, + })); + // 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(), + 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 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 +2690,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 +2707,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 +2718,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 +2776,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 +3259,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 +3348,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 +3994,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 @@ -3598,12 +4357,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 { @@ -3634,7 +4402,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. @@ -3655,6 +4431,342 @@ 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_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_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( + 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_") + ) + })); + // 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")) + })); + 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] @@ -4017,4 +5129,58 @@ mod tests { "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/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..97768ada 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 @@ -235,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); @@ -284,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); @@ -354,6 +396,92 @@ 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), + imported_prompt_body: String::new(), + }; + 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), + imported_prompt_body: String::new(), + }; + 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 @@ -373,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); @@ -415,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}"); @@ -453,8 +583,9 @@ mod tests { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: Some(input_path), + imported_prompt_body: String::new(), }; - 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)) => { @@ -489,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); @@ -529,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); @@ -579,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/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/extensions/mod.rs b/src/compile/extensions/mod.rs index 378b87c4..db137dd5 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -114,6 +114,11 @@ 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. `build_pipeline_ir` + /// for `inspect`/`graph`). Both `compile` and `check` resolve imports. + pub imported_prompt_body: String, } impl<'a> CompileContext<'a> { @@ -143,6 +148,7 @@ impl<'a> CompileContext<'a> { engine, compile_dir: Some(compile_dir), input_path: Some(input_path), + imported_prompt_body: String::new(), }) } @@ -193,6 +199,7 @@ impl<'a> CompileContext<'a> { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: None, + imported_prompt_body: String::new(), } } @@ -210,6 +217,7 @@ impl<'a> CompileContext<'a> { engine: crate::engine::Engine::Copilot, compile_dir: None, input_path: None, + imported_prompt_body: String::new(), } } @@ -223,6 +231,7 @@ impl<'a> CompileContext<'a> { engine: crate::engine::Engine::Copilot, compile_dir: Some(compile_dir), input_path: None, + imported_prompt_body: String::new(), } } } @@ -573,7 +582,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 +644,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..7db753cb --- /dev/null +++ b/src/compile/imports/alias.rs @@ -0,0 +1,516 @@ +//! 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, ImportEndpoint, 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}") +} + +/// 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 +/// 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, + endpoint_hash_discriminator(key.endpoint.as_ref()) + )) + ) + } else { + base_alias + }; + + // Map the typed endpoint to the ADO repository-resource `type` and + // 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, + 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: 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() + .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 { + 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.clone(), + }, + 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, + endpoint, + }), + 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"); + } + + 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![ + 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..24e14c5a --- /dev/null +++ b/src/compile/imports/integration_tests.rs @@ -0,0 +1,365 @@ +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, ImportEndpoint, ImportEntry, ImportSource, ParsedImportSpec, +}; +use crate::secure::CommitSha; + +const SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + +struct PanicFetcher; + +#[async_trait::async_trait] +impl ManifestFetcher for PanicFetcher { + async 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"); +} + +async fn resolve_local(entries: &[ImportEntry], base_dir: &Path) -> Vec { + resolve_imports(entries, base_dir, &PanicFetcher) + .await + .expect("resolve local imports") +} + +#[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"); + 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).await; + 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."); +} + +#[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"); + write_component( + &workflow_dir, + "components/deploy.md", + r#"--- +import-schema: + destination: + type: string + required: true +safe-outputs: + deploy: + run: "deploy --to {{ inputs.destination }}" + env: + DESTINATION: "{{ inputs.destination }}" +--- +Deploy to {{ 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).await; + 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}" + ); +} + +#[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"); + 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, + ) + .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}"); + + 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).await; + 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)); +} + +#[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!( + 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(ImportEndpoint::GitHub { + name: "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, + endpoint: Some(ImportEndpoint::GitHub { + name: "github-service-connection".to_string(), + }), + }), + 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..2b853a13 --- /dev/null +++ b/src/compile/imports/merge.rs @@ -0,0 +1,773 @@ +//! 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 bodies. +/// +/// 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, + entries: &[ImportEntry], + base_dir: &Path, + repo_root: &Path, + fetcher: &dyn ManifestFetcher, +) -> Result<(String, String)> { + let resolved = resolve_imports_with_repo_root(entries, base_dir, repo_root, fetcher).await?; + 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. + 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 (mut 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 + ) + })?; + + // 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, + &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; + + 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 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. +/// +/// 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; + + /// 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; + }; + let Some(Value::Mapping(safe_outputs)) = fm_map.get_mut("safe-outputs") else { + 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; + }; + for (_tool_name, tool_cfg) in tools.iter_mut() { + let Value::Mapping(cfg) = tool_cfg else { + continue; + }; + + // 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-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( + 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(), + }, + } + } + + 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 component_cannot_spoof_provenance_keys() { + // 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"); + 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 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"); + 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..3fdcf698 --- /dev/null +++ b/src/compile/imports/mod.rs @@ -0,0 +1,1126 @@ +//! 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 anyhow::{Context, Result}; +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::Deserialize; + +use crate::compile::types::{ImportEndpoint, 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. +/// +/// `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: Send + Sync { + 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 { + async fn fetch(&self, spec: &ParsedImportSpec) -> Result> { + let route = format!( + "repos/{}/{}/contents/{}?ref={}", + spec.owner, + spec.repo, + spec.path, + spec.sha.as_str() + ); + + 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() { + 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}") + }) + } +} + +/// 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 (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. +/// +/// 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, + /// 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 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(), + 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().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 \ + 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(_) => unreachable!("github/ghe rejected above"), + }; + + let auth = self.auth().await.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 { + 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 async fn resolve_imports( + entries: &[ImportEntry], + base_dir: &Path, + fetcher: &dyn ManifestFetcher, +) -> Result> { + resolve_imports_with_repo_root(entries, base_dir, base_dir, fetcher).await +} + +/// 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 async 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) + .await + .with_context(|| format!("failed to resolve import `{}`", entry.uses))? + { + resolved.push(import); + } + } + Ok(resolved) +} + +async 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).await?; + 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); + } + // 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 `validate_import_path_segments` 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)) +} + +async 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())?; + // 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); + } + + let bytes = fetcher.fetch(spec).await?; + 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()))?; + // 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!( + "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 mut path = repo_root + .join(".ado-aw") + .join("imports") + .join(&spec.owner) + .join(&spec.repo) + .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<()> { + if value.is_empty() + || value == "." + || value == ".." + || value.contains('\\') + || value.contains('/') + { + anyhow::bail!( + "remote import {} contains an invalid path segment: `{}`", + label, + value + ); + } + Ok(()) +} + +/// 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(segments) +} + +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(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 {}", + 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)) +} + +pub(super) 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) + .ok_or_else(|| { + anyhow::anyhow!("import section `{}` heading could not be re-parsed", section) + })?; + + 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, + } + + #[async_trait] + impl ManifestFetcher for StaticFetcher { + async fn fetch(&self, _spec: &ParsedImportSpec) -> Result> { + Ok(self.bytes.clone()) + } + } + + struct PanicFetcher; + + #[async_trait] + impl ManifestFetcher for PanicFetcher { + async 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" + } + + #[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(); + 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, + ) + .await + .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()) + ); + } + + #[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 { + bytes: manifest().to_vec(), + }; + + let resolved = + resolve_imports_with_repo_root(&[entry], repo.path(), repo.path(), &fetcher) + .await + .unwrap(); + + let cache_file = repo + .path() + .join(".ado-aw") + .join("imports") + .join("acme") + .join("shared") + .join(SHA) + .join("components") + .join("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()) + ); + } + + #[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 + .path() + .join(".ado-aw") + .join("imports") + .join("acme") + .join("shared") + .join(SHA); + 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) + .await + .unwrap(); + + assert_eq!(resolved.len(), 1); + 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(); + 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) + .await + .unwrap_err(); + + assert!(format!("{err:#}").contains("exceeding the 262144 byte limit")); + } + + #[tokio::test] + async 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, + ) + .await + .unwrap_err(); + assert!( + format!("{err:#}").contains("nested (transitive) imports are not yet supported"), + "got: {err:#}" + ); + } + + #[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 { + 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) + .await + .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") + .join("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) + .await + .unwrap_err(); + assert!( + format!("{err:#}").contains("does not match its recorded digest"), + "got: {err:#}" + ); + } + + #[tokio::test] + async 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, + ) + .await + .unwrap(); + + assert_eq!(resolved[0].body, "## Two\ntwo\n### Detail\nkeep"); + } + + #[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( + &[import_entry("missing.md?")], + repo.path(), + repo.path(), + &PanicFetcher, + ) + .await + .unwrap(); + assert!(optional.is_empty()); + + let err = resolve_imports_with_repo_root( + &[import_entry("missing.md")], + repo.path(), + repo.path(), + &PanicFetcher, + ) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("failed to resolve import `missing.md`") + ); + } + + #[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( + &[import_entry(spec)], + repo.path(), + repo.path(), + &PanicFetcher, + ) + .await + .unwrap_err(); + assert!( + format!("{err:#}").contains("invalid segment"), + "spec `{spec}` should be rejected as traversal, got: {err:#}" + ); + } + } + + #[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!( + err.to_string() + .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::with_resolved( + 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::with_resolved( + 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/imports/schema.rs b/src/compile/imports/schema.rs new file mode 100644 index 00000000..d025426e --- /dev/null +++ b/src/compile/imports/schema.rs @@ -0,0 +1,895 @@ +//! `import-schema` modeling, consumer-`with` validation, and +//! `{{ 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 = "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 `{{ inputs. }}` placeholders in text. +/// +/// 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("{{") { + let start = cursor + relative_start; + output.push_str(&text[cursor..start]); + + // 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; + }; + let expression_end = expression_start + relative_end; + 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)), + None => output.push_str(original), + }, + _ => output.push_str(original), + } + + cursor = expression_end + 2; + } + + output.push_str(&text[cursor..]); + output +} + +/// 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 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("{{") { + 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 { + 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); + + let substituted_front_matter = substitute_front_matter(&stripped_front_matter, &inputs); + let substituted_body = substitute_inputs(body, &inputs); + + // 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 + // 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( + 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 {}", + super::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 {}", + super::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 {}", + super::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) +} + +/// 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) => crate::sanitize::sanitize_config(value), + 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 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={{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()); + + assert_eq!( + substituted, + concat!( + "name=demo ", + "key=secret ", + "count=7 ", + "enabled=false ", + "tags=[\"a\",\"b\"] ", + "config={\"apiKey\":\"secret\"} ", + "missing={{ 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 {{ inputs.name }} and ${{ inputs.name }}"; + let substituted = substitute_inputs(text, inputs.as_object().unwrap()); + assert_eq!( + substituted, + "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 {{ 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={{ 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( + r#" +name: "{{ inputs.name }}" +steps: + - bash: echo {{ inputs.config.apiKey }} +nested: + value: before {{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-{{ inputs.name }} +variables: + count: "{{ inputs.count }}" +"#, + ); + let with = json!({ "name": "demo" }); + + let (front_matter, body) = apply_import_inputs( + &fm, + "Hello {{ inputs.name }} {{ 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"); + } + + #[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 {{ inputs.typo }}", + with.as_object().unwrap(), + ) + .unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("unresolved import input placeholder"), "{msg}"); + assert!(msg.contains("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 ${{ 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/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/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 bc522ee8..c9597ef8 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; @@ -55,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; @@ -142,13 +152,29 @@ 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. + // + // `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 // characters, and enforces content limits across all config values. @@ -173,9 +199,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}"); } @@ -219,6 +243,7 @@ async fn compile_pipeline_inner( &yaml_output_path, &front_matter, &markdown_body, + &imported_prompt_body, skip_integrity, debug_pipeline, ) @@ -607,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(); @@ -631,6 +667,7 @@ pub async fn check_pipeline(pipeline_path: &str) -> Result<()> { pipeline_path, &front_matter, &markdown_body, + &imported_prompt_body, false, false, ) @@ -896,6 +933,60 @@ 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`. 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(); + 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. /// @@ -918,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(); @@ -936,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/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/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/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, diff --git a/src/compile/types.rs b/src/compile/types.rs index 110038cc..3c16271b 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,334 @@ 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 { + /// 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, + /// Typed remote source endpoint. `None` denotes same-org Azure Repos. + 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, + /// Typed source endpoint carried from the [`ImportEntry`]. `None` denotes + /// same-org Azure Repos (the primary compile-time fetch source). + pub endpoint: Option, +} + +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, + endpoint: self.endpoint.clone(), + })) + } +} + /// 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 +1597,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 +1641,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 +1718,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 +1790,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 +2168,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 +2195,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 +3102,260 @@ 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, + Some(ImportEndpoint::GitHub { + name: "shared-components-connection".to_string() + }) + ); + 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_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?")); + + 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, + Some(ImportEndpoint::GitHub { + name: "shared-components-connection".to_string() + }) + ); + 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 +3687,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 +3738,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 +3754,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 +3916,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 +4377,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 +4911,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 +4971,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..0c288c5e 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -6,12 +6,14 @@ 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::path::{Path, PathBuf}; +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::{ @@ -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,905 @@ 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", + }; + } + }; + + // 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().await { + 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, staged: bool) -> &'static str { + if result.is_budget_exhausted() { + "budget_exhausted" + } else if staged && result.success { + "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 + .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 { + 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 +1014,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 +1106,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 +1661,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 +2163,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 +2181,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 +2203,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 +2228,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 +2261,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 +2284,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 +2790,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 +2959,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 +2997,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 +3031,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/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] 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. 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");