feat(compile): reusable cross-repository custom safe-output components#1508
feat(compile): reusable cross-repository custom safe-output components#1508jamesadevine wants to merge 12 commits into
Conversation
eb19744 to
70cc1f9
Compare
🔍 Rust PR ReviewSummary: Looks good overall — solid architecture and good test coverage. Two issues worth addressing before merge. Findings🔒 Security Concerns
|
🔍 Rust PR ReviewSummary: Looks good overall — solid implementation with excellent test coverage and defense-in-depth. One genuine security gap found in the import-input substitution path. Findings🔒 Security Concerns
|
Address in-depth + Rust review findings on PR #1508. - HIGH: SHA-pinned component checkout no longer relies on a shallow fetch of `main` (which lacks the pinned object once main advances). Add a new `checkout-component` ado-script bundle that fetches the pinned commit (direct `git fetch origin <sha>`, then progressive deepening) over the ADO bearer, checks it out detached, and verifies HEAD == pin, failing closed. Replaces the raw-bash verify step. - MEDIUM: `run_custom_entrypoint` now drives the component subprocess with `tokio::process` and writes stdin concurrently with draining stdout/stderr, removing the blocked-runtime-thread and large-payload pipe-deadlock risks. - LOW: reject a component's own nested `imports:` with a clear error instead of silently dropping it; verify the committed manifest cache against a `.sha256` digest sidecar on read (tamper detection); derive the staged execution-record status from explicit state instead of matching a message prefix; de-duplicate the `yaml_value_kind` helper across the imports modules. Adds TS bundle tests, bundle-coverage/gitignore wiring, and Rust regression tests (bundle emission, nested-import rejection, cache-tamper detection). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
🔍 Rust PR ReviewSummary: Solid implementation with good security discipline. A few items worth addressing before merge. Findings🐛 Bugs / Logic Issues
🔒 Security Concerns
|
🔍 Rust PR ReviewSummary: Large, well-structured PR — the trust-boundary design is sound and error handling is thorough. Two concrete bugs worth fixing before merge, one in the cache layer and one in the repository-resource generator. Findings🐛 Bugs / Logic Issues1. fn flatten_import_path(path: &str) -> Result<String> {
Ok(path.replace('/', "_")) // "a/b.md" → "a_b.md"
}A remote import at path 2. resources.push(RepositoryResource::Named {
identifier: component.alias,
kind: "git".to_string(),
name: format!("{owner}/{repo}"),
r#ref: Some("refs/heads/main".to_string()),
endpoint: None, // ← always None
});
|
Add support for importing typed, secret-bearing custom safe-output tools from shared (optionally cross-repository, SHA-pinned) components, per #1473. - imports: front matter + resolver with a committed SHA-keyed manifest cache, import-schema validation/substitution, and a consumer-wins merge pass - repository-resource endpoint for GitHub / GitHub Enterprise sources, with compiler-synthesized import repo aliases and strict validation - config-driven dynamic MCP tools with compile-time closed, scalar-only input schemas generated from safe-outputs.scripts / safe-outputs.jobs - one dedicated, Detection-gated executor job per custom definition, with the executor CLI modes `execute --custom-config` (scripts-style native dispatch) and `execute --custom-phase pre|post` (jobs-style wrapper); reviewed tools route through ManualReview - versioned CustomExecutionRecord + audit component-provenance with a local marker cross-check - docs (docs/imports.md and updates) and a four-target acceptance matrix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
- resolve_local_path: reject path-traversal (`..`/`.`/empty segments and backslashes) so a local import cannot escape the workflow directory at compile time, matching the guard already applied to remote import paths - extract_markdown_section: replace a production `.expect()` with a fail-closed `ok_or_else(..)?` so a future refactor can't panic - render_json_value: run string import inputs through `sanitize_config` (neutralizes `##vso[` / `##[` pipeline commands, strips control chars) as defense-in-depth, and document the compile-time-author-choice trust boundary Adds regression tests for the traversal guard and pipeline-command neutralization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
Address in-depth + Rust review findings on PR #1508. - HIGH: SHA-pinned component checkout no longer relies on a shallow fetch of `main` (which lacks the pinned object once main advances). Add a new `checkout-component` ado-script bundle that fetches the pinned commit (direct `git fetch origin <sha>`, then progressive deepening) over the ADO bearer, checks it out detached, and verifies HEAD == pin, failing closed. Replaces the raw-bash verify step. - MEDIUM: `run_custom_entrypoint` now drives the component subprocess with `tokio::process` and writes stdin concurrently with draining stdout/stderr, removing the blocked-runtime-thread and large-payload pipe-deadlock risks. - LOW: reject a component's own nested `imports:` with a clear error instead of silently dropping it; verify the committed manifest cache against a `.sha256` digest sidecar on read (tamper detection); derive the staged execution-record status from explicit state instead of matching a message prefix; de-duplicate the `yaml_value_kind` helper across the imports modules. Adds TS bundle tests, bundle-coverage/gitignore wiring, and Rust regression tests (bundle emission, nested-import rejection, cache-tamper detection). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
Cross-repository `imports:` previously fetched manifests only from GitHub (`gh api`), while the runtime checkout already treated endpoint-less imports as same-org Azure Repos. Endpoint-less (Azure-Repos-intended) imports thus could not resolve their manifest at compile time and silently queried GitHub (source confusion). - Type the import `endpoint`: absent => same-org Azure Repos (primary); `github` (bare-string shorthand), `ghe` (+host), and `azure-repos` (+org, cross-org) object forms with per-variant validation. - Make the `ManifestFetcher` trait async (`#[async_trait]`); `GhCliFetcher` now uses `tokio::process` and sets `GH_HOST` for GHE. - Add `AdoRepoFetcher` (ADO Git Items API, SHA-pinned) and a `RoutingFetcher` that dispatches by endpoint type. Routing is fail-closed: an Azure-Repos import never falls back to GitHub and vice-versa. - Non-interactive ADO auth (SYSTEM_ACCESSTOKEN -> AZURE_DEVOPS_EXT_PAT -> az CLI) + `fetch_git_item` helper with ADO sign-in detection in src/ado. - Map cross-org Azure Repos -> `type: git` + connection, GHE -> `githubenterprise` in repository-resource synthesis. - Tests: endpoint parsing/validation, routing regression guard, fail-closed guards, cross-org/GHE alias mapping. Update docs/imports.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
…elimiter
Two coupled changes to how reusable `imports:` deliver content:
1. Delimiter: `${{ ado.aw.import-inputs.<key> }}` -> `{{ ado.aw.import-inputs.<key> }}`.
`${{ }}` is Azure DevOps' own template-expression delimiter, and our
substituted output is embedded directly into pipeline YAML / the agent
prompt where ADO template-processes any `${{ }}` it sees -- a footgun. The
compile-time `{{ }}` family is inert there. A `{{` preceded by `$` is left
verbatim so genuine `${{ parameters.x }}` is untouched. A new leftover-guard
fails compile on any unresolved `{{ ado.aw.import-inputs.* }}` (a body/FM
reference to an input the consumer did not supply and the schema did not
default) -- closing the leak vector regardless of delimiter.
2. Body delivery: imported component bodies are now inlined into the agent
prompt at compile time (they are substituted only at compile time and cannot
be re-derived at runtime from the consumer's own source). In the default
`inlined-imports: false` mode the consumer body remains a `{{#runtime-import}}`
marker, with imported bodies inlined ahead of it (imports-first). Previously
the default mode discarded the merged body entirely, silently dropping
imported prose + substitutions. Mirrors gh-aw, which compile-inlines
input-bearing imports and runtime-imports only the main body.
merge_imports now returns (imported_body, combined_body); the imported body is
threaded via CompileContext to build_agent_content. Verified end-to-end: a
compiled workflow inlines the substituted imported body before the consumer's
runtime-import marker.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
The `ado.aw.import-inputs.` namespace was a vestigial port of gh-aw's
`github.aw.import-inputs.`. gh-aw needs that namespace only to avoid colliding
with GitHub Actions' native `${{ inputs.x }}` expression; Azure DevOps has no
such construct, and ado-aw already uses the compile-time `{{ }}` delimiter (not
ADO's `${{ }}`). So the shorter, cleaner `{{ inputs.X }}` is unambiguous and
safe. PLACEHOLDER_PREFIX is now `inputs.`; tests, docs, and the leftover-guard
message updated. End-to-end compile re-verified.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
The imported custom-safe-output component repository resource
(`custom_repository_resources`) hardcoded `ref: refs/heads/main`. Azure DevOps
hard-fails the checkout when the ref does not exist ("Couldn't find remote ref
refs/heads/main", no fallback), so any component whose repo default branch is
not `main` produced a pipeline that failed at the component-checkout step.
The ref is vestigial here: the checkout-component bundle re-fetches the exact
pinned SHA at runtime, so the initial shallow checkout only needs a branch that
exists. Omitting `ref` makes ADO use the repo's actual default branch. Adds a
regression test asserting the resource omits `ref`.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
…isions The import manifest cache flattened `/` -> `_` in the filename (`components/deploy.md` -> `components_deploy.md`). That mapping is not injective: `a/b.md` and `a_b.md` from the same repo+SHA collided onto one cache file, silently serving one component's manifest for the other (the digest sidecar records the cached file's own hash, so it could not detect the mismatch), bypassing the SHA-integrity guarantee. Preserve the component's directory structure under the SHA dir instead (`<sha>/components/deploy.md`), which is injective and mirrors the source repo. The existing traversal guard (now `validate_import_path_segments`) keeps nested joins safe. Feature is unreleased, so no committed cache needs migrating. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
`check_pipeline` recompiled the source to compare against the committed lock YAML but skipped `imports:` resolution entirely, so any import-using workflow reported false "drift" (the recompile was missing imported front matter and inlined imported bodies), making `ado-aw check` unusable for imports. Extract the import resolve-and-merge logic from `compile_pipeline_inner` into a shared async `resolve_and_merge_imports` helper and call it from both compile and check. `check` now validates the same fully-merged pipeline `compile` produces. It reads the committed SHA-keyed `.ado-aw/imports` cache, so it stays offline when the cache is vendored (the fetcher is only invoked on a cache miss). Adds an end-to-end regression test (compile + check on a local-import workflow). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
…checkout Remote custom-safe-output component imports never had their provenance stamped onto the merged tool config, so `custom_repository_resources` (which requires `component-source`/`component-sha`) emitted nothing and the component repo was never checked out at runtime — the headline cross-repository custom safe-output feature was inert for real imports, working only when the provenance keys were hand-authored. And even the resource path hardcoded `kind: git` / no endpoint, so GitHub/GHE/cross-org components would be mis-typed. - Stamp `component-source`/`component-sha`/`manifest-digest` plus the resolved `component-repo-type` (git|github|githubenterprise) and `component-endpoint` (service connection) onto each remote import's `safe-outputs.scripts|jobs` tools during merge (local imports, checked out via self, are untouched). - `custom_repository_resources` now reads the stamped repo-type/endpoint instead of hardcoding them, so the executor job checks the component out with the correct resource type and service connection. - Extract the endpoint -> (repo_type, connection) mapping into a shared `endpoint_repo_type_and_connection` reused by the alias synthesizer. Adds merge-stamping tests (remote github/same-org azure, local not stamped) and resource-mapping tests (github/ghe). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
Code review found that stamp_component_provenance only conditionally inserted `component-endpoint` (when the import resolved a service connection). For a same-org (endpoint-less) remote import the connection is None, so a `component-endpoint` value authored into the component's own front matter survived the merge and was used as the checkout repository-resource endpoint — letting a component inject an arbitrary service connection onto its own checkout, violating the "compiler owns provenance" invariant. Harden: strip ALL compiler-owned `component-*` keys (source/sha/manifest-digest/ repo-type/endpoint) from every imported component (local and remote) before stamping, then stamp compiler-resolved values for remote imports only. This also removes the local-import footgun where a preset component-source/sha would synthesize a spurious checkout resource. Adds spoofing-guard tests. Also correct the now-stale CompileContext.imported_prompt_body doc (check does resolve imports; the unresolved path is build_pipeline_ir for inspect/graph). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
…line Two review follow-ups: A. `build_pipeline_ir` (powers `inspect`/`graph`/`whatif`/`lint`/`trace`) now calls the shared `resolve_and_merge_imports`, so those commands reason about the same fully-merged pipeline `compile`/`check` produce (imported tools, safe-outputs, custom jobs, inlined bodies) instead of the pre-merge front matter. Adds an inspect-with-imports regression test. B. `AdoRepoFetcher` now resolves its consumer org URL + non-interactive auth LAZILY on the first actual fetch (cached via `tokio::sync::OnceCell`) instead of eagerly at construction. The import cache is consulted before any fetch, so a fully-vendored committed cache — as used by `check`/`inspect` — performs no `git`/`az` subprocess or network work. Fail-closed behaviour is preserved (GitHub/GHE spec rejected before any resolution; unresolved org/auth errors only on an actual uncached Azure fetch). Test-only `with_resolved` constructor keeps the fetcher unit tests subprocess-free. `ManifestFetcher` gains a `Send + Sync` supertrait bound so a `&dyn ManifestFetcher` can be held across an await in the `Send` future the `mcp-author` tool router requires (surfaced by A). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c012f03-9e0f-4259-996b-fcb46b13a92f
e527ef6 to
b9dca14
Compare
🔍 Rust PR ReviewSummary: Solid, well-architected feature with good security hardening. One logic bug in the merge layer and a few suggestions. Findings🐛 Bugs / Logic Issues
|
Summary
Implements #1473: reusable, cross-repository custom safe-output components. Workflow authors can publish typed, secret-bearing safe-output integrations once in a shared (optionally cross-repo, SHA-pinned) component and consume them from many workflows — flowing through the existing Agent → Detection → (approval) → privileged executor trust boundary.
Two mechanisms (gh-aw parity), one dedicated Detection-gated job per definition, both with scoped secrets:
safe-outputs.scripts.<name>— entrypoint / native dispatch: the job runs a singleado-aw execute --custom-config(the executor owns the loop).safe-outputs.jobs.<name>— arbitrary ADOsteps:: the job runsexecute --custom-phase pre→ author steps →execute --custom-phase post.What's included
imports:front matter (bare spec or{ uses, with, endpoint }),owner/repo/path@<40-char-sha>resolver with a committed, markdown-only SHA-keyed manifest cache under.ado-aw/imports/<owner>/<repo>/<sha>/<path>(directory structure preserved — no lossy flattening),import-schemavalidation, and a consumer-wins front-matter merge pass (gated on non-emptyimports:).asyncand routes by a typed endpoint: absent ⇒ same-org Azure Repos (ADO Git Items API viaSYSTEM_ACCESSTOKEN→AZURE_DEVOPS_EXT_PAT→az),type: azure-repos⇒ cross-org Azure Repos,type: github/type: ghe⇒ GitHub / GitHub Enterprise viagh. Routing is fail-closed (an Azure-Repos import can never silently fall back to GitHub, and vice-versa) and the compile-time fetch source always matches the runtime checkout source. Auth/org resolve lazily on first fetch, so a fully-vendored committed cache (as used bycheck/inspect) does zerogit/azwork.import-schemainputs are substituted at compile time as{{ inputs.<key> }}— a compile-time{{ }}-family marker, deliberately not the ADO${{ }}template delimiter (whose output would be re-processed by ADO once embedded in pipeline YAML). A$-preceded{{is left verbatim, and any unresolved{{ inputs.* }}is a hard compile error.{{#runtime-import}}marker in the default mode (gh-aw parity).component-source/sha/manifest-digest+ resolved repo-type/service-connection) stamped onto their tools during merge; the executor job checks the component out with the correct repository-resourcetype/endpointand pins the exact commit via the fail-closedcheckout-componentbundle (no hardcodedrefs/heads/main— ADO uses the repo default branch).ToolRoute::new_dyn) from compile-time-generated closed, scalar-only JSON schemas (additionalProperties: false).Custom_<tool>job per definition;require-approvalcustom tools route throughManualReview; versionedCustomExecutionRecordwith compiler-owned provenance.compile,check, andinspect/graphall resolve imports via one shared merge helper, so the drift check and the read-only IR queries reason about the same fully-merged pipelinecompileproduces.ado-aw auditwith a local# ado-aw-metadatamarker cross-check finding.docs/imports.mdplus updates todocs/safe-outputs.md,docs/front-matter.md,docs/network.md, andAGENTS.md.Security / hardening (from in-branch review)
component-*provenance keys — author-provided values on an imported component are stripped before stamping, so a component cannot spoof its own checkout (inject a service connection or redirect the source).a/b.mdvsa_b.mdcollision) so one component's manifest can't be silently served for another..sha256digest sidecar on read; nested/transitive imports rejected; path-traversal guarded on both local and remote import paths.Intentionally deferred (documented)
Transitive/nested imports (depth ≤ 3); array/object agent inputs; Bitbucket sources.
Test plan
cargo test --bin ado-aw— 2681 unit/bin tests green (incl. newimports,custom_tools,mcp_custom_tools, custom-job, audit-provenance, endpoint-routing, delimiter/leftover-guard, body-delivery, provenance-stamping/spoofing-guard, and the four-target acceptance matrix incompiler_tests).cargo clippy --all-targets— clean.compiler_tests,codemod_tests,inspect_integration,bash_lint_tests) — green.main; one incidental test conflict resolved in favour of main's stronger assertion (test: correct mislabelled vacuous test in src/compile/types.rs #1518).