Skip to content

feat(compile): gate high-impact safe outputs behind manual review - #1196

Merged
jamesadevine merged 10 commits into
mainfrom
feat/safe-output-manual-review
Jun 28, 2026
Merged

feat(compile): gate high-impact safe outputs behind manual review#1196
jamesadevine merged 10 commits into
mainfrom
feat/safe-output-manual-review

Conversation

@jamesadevine

Copy link
Copy Markdown
Collaborator

Summary

Wires the previously-unused ManualValidation@1 builder into the compiler so agents can propose higher-impact safe outputs that a human approves before Stage 3 applies them.

Manual review is opt-in (default false) and configured under the existing safe-outputs: map, matching current conventions:

safe-outputs:
  require-approval: true          # section-level default for every tool
  create-pull-request: {}
  add-pr-comment:
    require-approval: false       # per-tool override wins over the default

require-approval accepts a bare boolean or an object for finer control:

require-approval:
  approvers: ["[Org]\\release-team"]   # empty → anyone with run permission
  notify-users: ["ops@example.com"]    # empty → no email
  timeout-minutes: 120                  # omit → job/stage timeout
  on-timeout: reject                    # default — fail-closed
  instructions: "Verify the proposed PR before approving."

Resolution / defaults: per-tool require-approval > section-level > unset (false). A bare require-approval: true pauses the run, lets anyone with run permission approve/reject, sends no emails, and fails closed on timeout.

Compiled pipeline shape

  • New agentless ManualReview job (pool: server, ManualValidation@1) inserted between Detection and SafeOutputs.
  • Automatic outputs always execute — the auto SafeOutputs job depends only on Agent + Detection (--exclude the reviewed tools) and runs in parallel with the review pause, independent of the approval outcome.
  • Reviewed outputs run in a separate SafeOutputs_Reviewed job (--only the reviewed tools), gated behind ManualReview, publishing a distinct safe_outputs_reviewed artifact.
  • The gate only pauses when Detection cleared the run and the agent actually proposed a reviewed-type output (a Detection step sets HasReviewedProposals) — no pointless pauses.
  • The Detection threat gate still runs first: a flagged run applies nothing, automatic or reviewed.

Implementation notes

  • RequireApproval/ApprovalConfig types + per-tool resolution (types.rs); the reserved require-approval key is filtered everywhere safe-outputs: keys are treated as tool names (common.rs, main.rs).
  • New Pool::Server agentless IR variant emitting the scalar pool: server (ir/job.rs, ir/lower.rs, ir/summary.rs); 1ES path skips the templateContext wrap for server jobs (onees_ir.rs).
  • ado-aw execute gains --only/--exclude tool filters (execute.rs, main.rs).
  • Audit aggregates execution records across both safe-output artifacts (audit/analyzers/safe_outputs.rs).
  • Shared canonical-graph wiring covers all targets (standalone, 1es, job, stage).

Test plan

  • cargo build
  • cargo clippy --all-targets --all-features — clean
  • cargo test — 2237 unit tests + all integration suites green
  • cargo test --test bash_lint_tests — shellcheck passes on the new bash bodies
  • New tests: global gate, no-gate (backward compat), detailed object propagation, mixed auto/reviewed split, executor ToolFilter, conditional pause (HasReviewedProposals), and audit aggregation across split artifacts
  • Existing golden lock files unchanged (the feature is inert without require-approval)

Wire the ManualValidation@1 builder into the canonical pipeline so safe
outputs can require human approval via a new agentless ManualReview job.

- safe-outputs.require-approval (section default) + per-tool override,
  accepting a bool or { approvers, notify-users, timeout-minutes,
  on-timeout, instructions }; reserved key filtered from tool enumeration
- Pool::Server agentless IR variant emitting `pool: server`
- mixed config splits Stage 3 into an automatic SafeOutputs job and a
  gated SafeOutputs_Reviewed job (distinct safe_outputs_reviewed artifact)
- executor --only/--exclude tool filter
- ManualReview only pauses when Detection cleared the run and the agent
  actually proposed a reviewed output (HasReviewedProposals); fail-closed
- audit aggregates execution records across both safe-output artifacts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Well-designed feature with one correctness issue that could silently bypass the approval gate on misconfiguration, plus a minor false-positive risk in the proposal-detection heuristic.


Findings

🐛 Bugs / Logic Issues

src/compile/types.rs — Silent failure on malformed require-approval config

Both global_require_approval() and tool_require_approval() use .ok() to discard deserialization errors:

.and_then(|v| serde_json::from_value::<RequireApproval>(v.clone()).ok())

If the user writes a syntactically legal YAML value that fails to deserialize as RequireApproval — for example a typo like on-timeout: rejec — the method silently returns None. Because tool_requires_approval() only returns Some when the setting exists and deserializes correctly, the tool silently falls out of the reviewed list, no ManualReview gate is emitted, and high-impact outputs bypass the intended approval step with no error or warning.

ApprovalConfig uses #[serde(deny_unknown_fields)], so even a mild typo in any field name will silently disable the gate for that tool.

The validate_safe_outputs_keys function validates key names but not require-approval values. The fix is to attempt the deserialization there (or in a dedicated validate_require_approval_config call) and surface it as a compilation error rather than None.


🔒 Security Concerns

None beyond the silent-failure issue above (which is correctness as much as security).


⚠️ Suggestions

src/compile/agentic_pipeline.rs:detect_reviewed_proposals_step — grep may produce false positives

if grep -Eq '"name"[[:space:]]*:[[:space:]]*"({alternation})"' "$PROPOSALS"

This pattern scans the raw NDJSON text and will match a "name" key anywhere in the document, including inside a tool's params. For example, a create-work-item output whose params happen to contain "name": "create-pull-request" would set HasReviewedProposals=true and unnecessarily pause the run. The consequence is a spurious human-review pause (not a security bypass, since ManualReview still requires approval), but it degrades UX.

A more robust check would anchor to top-level object keys, e.g. using jq if it is available in the agent environment, or tightening the pattern to only match at the start of a JSON object (^\{"name": with -E). Alternatively, since ado-aw itself is available on PATH in Stage 2, a dedicated ado-aw subcommand could be the right long-term approach.

src/compile/agentic_pipeline.rs:filter_flags — unquoted tool names in generated bash

s.push_str(&format!(" {flag} {t}"));

Tool names are not quoted before being spliced into the bash script. This is currently safe because is_safe_tool_name restricts them to [a-zA-Z0-9-]. However the correctness depends on the validation pass having run before build_safeoutputs_job is called — there is no type-level guarantee. A defense-in-depth quoting (" {flag} \"{t}\"") would make the relationship explicit.

src/compile/agentic_pipeline.rs:aggregate_approval_configinstructions first-wins is undocumented at the YAML level

When multiple reviewed tools each specify instructions, only the first one encountered is used. This is a reasonable simplification for a single-gate pipeline, but the docs (docs/safe-outputs.md) don't mention this: a user who sets per-tool instructions for two reviewed tools will silently lose one. Worth a one-liner note in the doc.


✅ What Looks Good

  • SAFE_OUTPUT_RESERVED_KEYS + safe_output_tool_names(): filtering the reserved key out of every tool-name iteration path (generate_enabled_tools_args, validate_safe_outputs_keys, build_execution_context) is thorough and the unit test in common.rs covers it well.
  • Pool::Server IR variant: the scalar pool: server lowering is clean, and the explicit guard in onees_ir.rs that skips the 1ES templateContext wrap for server jobs is exactly the right approach.
  • aggregate_approval_config fail-closed default: all_resume initialized to true but set to false for every tool whose on_timeout is None correctly implements "reject unless every config explicitly opts out" semantics.
  • build_execution_context fix: filtering require-approval out of ctx.tool_configs before Stage 3 is important correctness work — the executor must never see reserved keys as tool configs.
  • SafeOutputsVariant abstraction: cleanly encapsulates the three execution shapes (single-default, auto, reviewed) without duplicating job-builder logic.
  • Backward compatibility: the reviewed.is_empty() || auto.is_empty() gate that collapses to default_single() ensures existing pipelines are unaffected, and the golden lock files remaining unchanged confirms this.
  • Test coverage: the combination of unit tests in types.rs, the lower IR test for pool: server, the four new integration tests in compiler_tests.rs, and the audit aggregation test is solid.

Generated by Rust PR Reviewer for issue #1196 · 273.3 AIC · ⌖ 12.6 AIC · ⊞ 34.9K ·

jamesadevine and others added 2 commits June 27, 2026 22:33
…w-proposal detection

Address the automated PR review of #1196:

- validate every require-approval value (section-level + per-tool) so a typo
  or unknown field surfaces as a compilation error instead of being silently
  dropped by the .ok() paths — previously a malformed config could let a
  high-impact safe output bypass the ManualReview gate
- detect_reviewed_proposals_step now matches only the top-level .name of each
  NDJSON object via jq (with a fail-safe broad-grep fallback), avoiding a
  spurious pause when a tool's params contain a nested "name" key
- document the single-gate instructions first-wins behaviour in safe-outputs.md
- add a manual-review fixture to the bash-lint harness so the detection step
  is shellchecked, plus unit tests for the new validation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Looks good overall — well-architected feature with strong security defaults and thorough tests. Two issues worth addressing.


Findings

🐛 Bugs / Logic Issues

aggregate_approval_configall_resume starts optimistic, continue keeps it that way
(src/compile/agentic_pipeline.rs, aggregate_approval_config)

let mut all_resume = true;
for tool in reviewed {
    let Some(cfg) = front_matter.tool_requires_approval(tool) else {
        continue;   // ← does NOT set all_resume = false
    };
    match cfg.on_timeout {
        Some(ApprovalOnTimeout::Resume) => {}
        _ => all_resume = false,
    }
}

If tool_requires_approval returns None for any tool in reviewed (shouldn't happen today, but could be a future regression), the continue leaves all_resume = true. That means the aggregated gate would silently default to on-timeout: resume — the opposite of fail-closed — even though no tool explicitly asked for it.

This is a security-sensitive code path (approval bypass on timeout). The conservative fix is to treat the missing-config case as "not resume":

let Some(cfg) = front_matter.tool_requires_approval(tool) else {
    all_resume = false;  // no config → fail-closed by default
    continue;
};

Teardown may be skipped when SafeOutputs_Reviewed is skipped (mixed split case)
(src/compile/agentic_pipeline.rs, wire_explicit_dependencies)

In the mixed split scenario (some auto, some reviewed tools), SafeOutputs_Reviewed gets added to Teardown's depends_on. In ADO, ADO's implicit succeeded() gate on a dependency means Teardown is skipped if SafeOutputs_Reviewed is skipped — which happens whenever the agent proposed no reviewed-type outputs that run. This means teardown scripts don't fire even though the auto SafeOutputs job ran successfully.

The pre-existing single-job form has the same issue (Teardown skips when SafeOutputs is skipped), so this isn't a regression per se. But adding a second skippable dependency to Teardown's depends_on increases the surface area. A succeededOrSkipped condition on SafeOutputs_Reviewed (or moving to an explicit Condition::SucceededOrFailed on the Teardown job itself) would prevent the gap.


⚠️ Suggestions

filter_flags embeds tool names without quoting — add inline comment
(src/compile/agentic_pipeline.rs, filter_flags)

The safety of embedding tool names directly in the bash command string (no shell quoting around each name) relies on validate::is_safe_tool_name having already rejected names containing shell metacharacters. This invariant holds today (validate_safe_outputs_keys runs before build_canonical_jobs), but the function body contains no comment to that effect. The analogous comment in detect_reviewed_proposals_step does explain the invariant — adding it to filter_flags would make the coupling explicit.


✅ What Looks Good

  • validate_require_approval in build_pipeline_context — eagerly re-parses every require-approval value after the initial .ok() silent-swallow paths, so a typo like on-timeout: rejec surfaces as a compilation error instead of silently removing the gate. The regression test confirming this is exactly right.

  • safe_output_tool_names() iterator — cleanly centralises the reserved-key filter for require-approval. All downstream consumers (validate_safe_outputs_keys, generate_enabled_tools_args, build_execution_context) route through it; no gap where the key could leak through as a tool name.

  • Fail-closed defaults throughout — bare require-approval: true defaults to on-timeout: reject; aggregate_approval_config defaults to reject unless every contributing tool explicitly sets resume; ManualReview gate is conditioned on HasReviewedProposals so a run never stalls for a human when nothing requires review.

  • Sorted partition outputpartition_safe_outputs_by_approval sorts both lists, making --only/--exclude flags and the bash alternation pattern deterministic across runs and golden-file safe.

  • Test coverage — the typo regression test (on-timeout: rejec), the deny_unknown_fields test, the execution_records_aggregate_across_split_artifacts audit test, and the four compiler integration tests cover the critical branches well.

Generated by Rust PR Reviewer for issue #1196 · 297.6 AIC · ⌖ 12.1 AIC · ⊞ 34.6K ·

jamesadevine added a commit that referenced this pull request Jun 27, 2026
…from gated job

Address the second automated PR review of #1196:

- aggregate_approval_config: a reviewed tool with no resolvable config now
  forces on-timeout=reject (fail-closed) instead of leaving all_resume=true,
  closing a potential approval-bypass-on-timeout regression
- wire_explicit_dependencies: in the mixed split, Teardown depends only on the
  automatic SafeOutputs job, never on the human-gated SafeOutputs_Reviewed job
  (which is routinely skipped and can stay paused indefinitely) — cleanup now
  fires on the common no-reviewed-proposal path and never blocks on approval
- filter_flags: document where the unquoted-tool-name safety invariant is
  enforced (validate_safe_outputs_keys before build_canonical_jobs)
- add a regression test asserting Teardown skips the reviewed-job dependency

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…from gated job

Address the second automated PR review of #1196:

- aggregate_approval_config: a reviewed tool with no resolvable config now
  forces on-timeout=reject (fail-closed) instead of leaving all_resume=true,
  closing a potential approval-bypass-on-timeout regression
- wire_explicit_dependencies: in the mixed split, Teardown depends only on the
  automatic SafeOutputs job, never on the human-gated SafeOutputs_Reviewed job
  (which is routinely skipped and can stay paused indefinitely) — cleanup now
  fires on the common no-reviewed-proposal path and never blocks on approval
- filter_flags: document where the unquoted-tool-name safety invariant is
  enforced (validate_safe_outputs_keys before build_canonical_jobs)
- add a regression test asserting Teardown skips the reviewed-job dependency

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jamesadevine
jamesadevine force-pushed the feat/safe-output-manual-review branch from 50e7841 to d552c82 Compare June 27, 2026 22:07
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Solid implementation overall — fail-closed by design, well-tested, and backward-compatible. One bug in an error message found; everything else looks correct.

Findings

🐛 Bugs / Logic Issues

  • src/compile/types.rs:877-878 — Wrong values in error message

    validate_require_approval emits this help text on a bad config:

    on-timeout (allow | reject)
    

    But ApprovalOnTimeout uses #[serde(rename_all = "kebab-case")] and has variants Reject and Resume — the valid YAML values are reject and resume. Saying allow instead of resume will mislead users who hit a typo in on-timeout.

    Fix:

    // line 877-878
    approvers, notify-users, timeout-minutes, on-timeout \
    (resume | reject), instructions. See docs/safe-outputs.md."

✅ What Looks Good

  • Fail-closed everywhere it matters: default on_timeout is reject; aggregate_approval_config treats a missing on_timeout as reject; the ManualReview gate condition uses AND(Succeeded, SafeToProcess=true, HasReviewedProposals=true) so no pointless pauses and no approval bypass on unsafe runs.

  • validate_require_approval() early-exit: The explicit validation pass prevents the .ok() silent-swallow pattern in global_require_approval/tool_require_approval from silently downgrading a high-impact output from reviewed to automatic due to a typo.

  • detect_reviewed_proposals_step injection safety: Tool names come from safe_output_tool_names() which filters through is_safe_tool_name() ([a-zA-Z0-9-]+). No shell-dangerous chars can reach the jq alternation. The grep fallback is documented as over-matching rather than under-matching — correct fail-safe direction.

  • Pool::Server IR variant: Clean separation; 1ES path correctly skips templateContext wrapping for agentless jobs.

  • Teardown dependency design: Not depending on SafeOutputs_Reviewed in the mixed split is the right call — the comment explaining why is clear and matches ADO's implicit succeeded() semantics.

  • Audit find_execution_filesVec<PathBuf>: Collecting all executed.ndjson files across split artifacts and assigning contiguous indices avoids record collisions cleanly.

  • ToolFilter::allows() semantics: only + exclude interact sensibly; the empty-filter default preserves all existing call sites without changes (confirmed by the large batch of updated call sites in execute.rs).

  • Test coverage: New tests cover global gate, per-tool override, mixed split, all-reviewed, no-review, Teardown dependency, ToolFilter logic, and audit aggregation across split artifacts. All golden lock files unchanged confirms zero behavior change for existing pipelines.

Generated by Rust PR Reviewer for issue #1196 · 762.2 AIC · ⌖ 12.3 AIC · ⊞ 34.6K ·

…sage

The validate_require_approval help text listed the valid on-timeout values as
"(allow | reject)", but ApprovalOnTimeout serializes to "resume | reject"
(kebab-case). Correct the message so a user who typos on-timeout is pointed at
the actual valid values.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Looks good — the implementation is solid and security-conscious. Four minor observations worth considering before merge.


Findings

⚠️ Suggestions

src/compile/agentic_pipeline.rsdetect_reviewed_proposals_step: silent jq failure

if jq -r 'select(type=="object") | .name // empty' "$PROPOSALS" 2>/dev/null | grep -Eqx '({alternation})'; then

When jq is available but exits non-zero (e.g. a corrupt/truncated safe_outputs.ndjson), the 2>/dev/null silences the error and grep receives empty input → HAS_REVIEWED stays "false"ManualReview gate never fires. The comment says this is "fail-safe", but that's only true if Stage 3 also fails on the same corrupt file. If Stage 3 somehow recovers (or a future change makes it more tolerant), a reviewed-type output could bypass the approval gate.

Consider adding a || true note or jq ... || HAS_REVIEWED="false" followed by an explicit echo "##vso[task.logissue type=warning]jq failed..." so the audit log at least records when detection was inconclusive. Or—simpler—add || HAS_REVIEWED="false" with a warning ##vso log message on jq failure so it's visible in the build log.


src/compile/agentic_pipeline.rsaggregate_approval_config: missing invariant guard

fn aggregate_approval_config(front_matter: &FrontMatter, reviewed: &[String]) -> ApprovalConfig {
    let mut all_resume = true;
    ...
    for tool in reviewed { ... }
    // If reviewed is somehow empty: on_timeout = Resume (open gate)

The only caller (build_manual_review_job) already guards reviewed.is_empty(), but nothing inside aggregate_approval_config enforces this. If it were ever called with an empty slice, it returns on_timeout: Some(Resume) — a fail-open default. Adding debug_assert!(!reviewed.is_empty(), "aggregate_approval_config called with no reviewed tools") makes the invariant explicit and would catch future misuse in debug builds.


src/compile/ir/lower.rsPool::Server unreachable! arm

fn lower_pool(pool: &Pool) -> Value {
    if let Pool::Server = pool {
        return s("server");
    }
    let mut m = Mapping::new();
    match pool {
        ...
        Pool::Server => unreachable!("handled above"),
    }

Correct, but brittle: the exhaustive match arms are out of sync with the early-return guard. If Pool gains a new variant in the future, the compiler will force an arm into the match body but the reviewer must remember the guard exists above. Collapsing this into a single match expression would be cleaner:

fn lower_pool(pool: &Pool) -> Value {
    if matches!(pool, Pool::Server) {
        return s("server");
    }
    // ... or fold Server into the match directly
}

Minor — not blocking.


tests/compiler_tests.rs — no 1ES-target coverage for manual review

All five new integration tests use the implicit standalone target. The onees_ir.rs change (skip templateContext for Pool::Server jobs) has no direct test; the only coverage comes from bash_lint_tests which also uses the standalone fixture. A single target: 1es variant of test_require_approval_global_emits_manual_review_gate would confirm that the server-pool skip actually fires on the 1ES path and that the compiled artifact doesn't accidentally contain templateContext: ... under the ManualReview job.


✅ What Looks Good

  • Security invariant for tool-name injection is airtight: validate_safe_outputs_keysis_safe_tool_name (alphanumeric/hyphen only) runs in build_pipeline_context before build_canonical_jobs ever reaches detect_reviewed_proposals_step or filter_flags, so there's no path where an unvalidated name reaches a shell pattern.
  • validate_require_approval() regression test is excellent: The test for the silent-.ok()-discard bug (on-timeout: rejec) is exactly the right thing to pin.
  • SAFE_OUTPUT_RESERVED_KEYS constant + safe_output_tool_names() iterator: Clean, centralised fix for the reserved-key problem; every consumer that iterated safe_outputs.keys() directly is updated.
  • Fail-closed throughout: on-timeout defaults to Reject; aggregate_approval_config requires all configs to opt in to Resume for the gate to resume; a rejected ManualReview fails the gate and cascades to skip SafeOutputs_Reviewed. The fail-closed posture is consistently enforced.
  • Teardown dependency design is well-reasoned: The comment explaining why Teardown must not depend on SafeOutputs_Reviewed in the mixed split is precise and prevents a subtle always-block-or-skip pitfall.
  • detect_reviewed_proposals_step jq vs grep duality: Matching only the top-level .name key with jq to avoid nested-field false-positives, with an acknowledged over-matching fallback, is a good layered defence.

Generated by Rust PR Reviewer for issue #1196 · 548.4 AIC · ⌖ 12.9 AIC · ⊞ 34.7K ·

Address the latest automated review of #1196:

- detect_reviewed_proposals_step: when jq is present but exits non-zero (e.g.
  corrupt/truncated proposals), fall back to the broad raw scan (over-match,
  fail-safe) and log a warning, instead of silently leaving HasReviewedProposals
  false — closes a potential approval-gate bypass if Stage 3 ever tolerates a
  file jq rejects
- aggregate_approval_config: debug_assert the reviewed slice is non-empty so the
  fail-open (resume) default for an empty slice can't be reached via future
  misuse
- lower_pool: fold Pool::Server into a single exhaustive match, removing the
  early-return + unreachable! duplication so a new Pool variant is caught by
  exhaustiveness
- add a 1ES-target integration test asserting the ManualReview server job emits
  pool: server and is NOT wrapped in templateContext

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Looks good — well-structured feature with strong security defaults and comprehensive tests. Two minor concerns worth noting.

Findings

🔒 Security Concerns

  • src/compile/agentic_pipeline.rs (aggregate_approval_config) — The security-critical invariant protecting against fail-open on_timeout: Resume is guarded only by a debug_assert!:

    debug_assert!(
        !reviewed.is_empty(),
        "aggregate_approval_config called with no reviewed tools (would default to fail-open resume)"
    );

    In a release build, calling this with an empty reviewed slice yields on_timeout: Some(Resume) — a fail-open gate. The call site (build_manual_review_job) already guards this correctly with an is_empty() check before calling, so no current path triggers it. But for a security invariant of this nature, a regular assert! (which fires in release builds too) would be more defensive. Since this is a compiler binary rather than a hot path, the performance argument for debug_assert! doesn't apply.

  • src/compile/types.rs (ApprovalConfig.instructions / approvers / notify_users) — These string fields are not checked for ADO template expressions (${{...}}). A value like approvers: "${{ variables['secret-token'] }}" would be expanded by ADO's YAML template engine at queue time before the ManualValidation@1 task sees it. This is an author-controlled field so the risk is low, but it's inconsistent with how other user-supplied strings (e.g. source_path) are validated via contains_ado_expression.

⚠️ Suggestions

  • src/compile/agentic_pipeline.rs (detect_reviewed_proposals_step) — The grep fallback (when jq is unavailable) scans raw text and can false-positive on nested "name" keys inside a tool's params (e.g. a create_work_item param named "name": "create-pull-request"). This is already documented in the comment as an intentional over-match (fail-safe rather than fail-open), which is the right tradeoff. The note is just so reviewers are aware this is a known limitation of the fallback path, not a bug.

  • src/compile/agentic_pipeline.rs (SafeOutputsVariant::default_single in the all-reviewed case) — When auto.is_empty() (every configured tool requires approval), the single SafeOutputs job uses default_single() with no filter flags. This means diagnostic safe outputs (noop, report_incomplete) — which are always enabled but not listed in safe_outputs: — only execute after human approval. This edge-case behaviour isn't explicitly documented in docs/safe-outputs.md and could surprise authors.

✅ What Looks Good

  • Fail-closed by default throughout: bare require-approval: trueon_timeout: Noneall_resume = falseon_timeout: Reject. Only overridable by every contributing tool explicitly setting on-timeout: resume.
  • validate_require_approval eagerly re-deserialises every require-approval value: catches the silent-bypass bug (.ok()-swallowed typos like on-timeout: rejec) before compilation, not silently dropping the gate.
  • filter_flags safety argument is well-founded: tool names are restricted to ASCII alphanumeric + hyphens by is_safe_tool_name, called before reaching this function, and the validation tests cover injection patterns.
  • Pool::Server lowering: emits the scalar pool: server correctly; the VmImage/Named mapping construction is now correctly scoped inside each branch — the old code created a Mapping unconditionally and only populated it inside the match arm, which is now fixed as a side-effect of this PR.
  • 1ES path correctly skips templateContext for server jobs: a ManualValidation@1 task in a templateContext wrapper would silently suppress the server pool, so the Pool::Server equality guard in onees_ir.rs is the right fix.
  • Graph wiring is correct for all three split cases (none / mixed / all-reviewed): the has_review && !has_reviewed_job condition in wire_explicit_dependencies produces the right dependsOn edges.
  • Tests cover the important cases: global gate, no-gate (backward compat), per-tool override, mixed split, teardown skips reviewed dependency, 1ES server pool, object propagation, and audit aggregation.

Generated by Rust PR Reviewer for issue #1196 · 393.7 AIC · ⌖ 12.3 AIC · ⊞ 34.7K ·

Address the latest automated review of #1196:

- reject ADO template expressions (`${{ ... }}`) in the require-approval
  approvers/notify-users/instructions fields — these expand at queue time
  before ManualValidation@1 sees them, so an author value like
  `${{ variables['secret-token'] }}` could leak a pipeline value into the
  gate. Runtime macros (`$(...)`) are intentionally still allowed since
  instructions documents `$(...)` interpolation. Adds a narrow
  contains_ado_template_expression helper.
- upgrade the aggregate_approval_config empty-slice guard from debug_assert!
  to assert! so the fail-open (resume) default can't be reached in release
  builds either (security boundary; compiler is not a hot path)
- document the all-reviewed edge case in safe-outputs.md: when every tool is
  gated, the single SafeOutputs job (incl. always-on diagnostics noop/
  report_incomplete/missing-*) is deferred behind approval; leave one tool
  non-gated to keep diagnostics automatic

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Nearly production-ready — one correctness bug with on-timeout: resume + timeout-minutes worth fixing before merge; everything else looks solid.

Findings

🐛 Bugs / Logic Issues

  • src/compile/agentic_pipeline.rs / src/compile/ir/tasks/manual_validation.rstimeout-minutes only sets the agentless job-level timeout (job.timeout), but ManualValidation@1's onTimeout handler fires when the task times out (via its own timeout input), not when the job times out. A job-level timeout cancels the job; the task never gets a chance to apply onTimeout: resume.
    For on-timeout: reject this is harmless (cancellation ≈ rejection). For on-timeout: resume, a timed-out run will cancel/fail instead of auto-approving — directly contradicting the documented on-timeout: resume behaviour.
    Fix: add a timeout(u32) setter to ManualValidation (emitting the timeout input in minutes), and call it in build_manual_validation_step alongside the existing job.timeout assignment. The job timeout can remain as the outer hard bound; the task timeout triggers the graceful onTimeout path.

✅ What Looks Good

  • Security posture is strong: validate_require_approval() is called before any partition/access in build_pipeline_context, so the .ok()-swallowed-error paths in global_require_approval/tool_require_approval can never silently drop a gate and let a reviewed tool slip through as automatic. The explicit regression test for the on-timeout: rejec typo is a nice addition.
  • Shell-injection surface is well-contained: filter_flags embeds tool names unquoted into the ado-aw execute command line, but validate_safe_outputs_keys runs is_safe_tool_name (ASCII alphanumeric/hyphen only) before any partition happens, so there's nothing exploitable there. Same guarantee covers the alternation in detect_reviewed_proposals_step's grep pattern.
  • ADO template-expression injection blocked: validate_require_approval correctly rejects ${{ ... }} in approvers, notify-users, and instructions at compile time.
  • Fail-closed defaults everywhere: aggregate_approval_config's all_resume = true logic correctly treats an absent on-timeout as non-resume (the wildcard _ => all_resume = false arm), and the assert!(!reviewed.is_empty()) guards the degenerate empty-slice case.
  • Backward-compatible: the feature is entirely inert when no require-approval is set; the golden lock files remaining unchanged confirms this.
  • Test coverage is thorough: global/per-tool resolution, mixed split, teardown dependency, object propagation, audit aggregation, and the silent-error regression case are all covered.

Generated by Rust PR Reviewer for issue #1196 · 382.1 AIC · ⌖ 12.4 AIC · ⊞ 34.7K ·

… fires

timeout-minutes previously only set the agentless job-level timeout, which
cancels the job — ManualValidation@1's onTimeout handler never runs. For
on-timeout: resume this meant a timed-out run was cancelled/failed instead of
auto-approving, contradicting the documented behaviour.

- add a timeout_minutes(u32) setter to the ManualValidation builder that sets
  the step-level timeoutInMinutes (ManualValidation@1 has no 'timeout' input;
  the control option is what triggers onTimeout)
- set it from approval.timeout_minutes in build_manual_validation_step
- keep the job-level timeout as a strictly-larger outer hard bound
  (timeout + 5min grace) so a job cancellation can never preempt the task's
  graceful onTimeout — an equal job timeout would re-introduce the bug
- docs + builder unit tests + an integration test asserting the task carries
  timeoutInMinutes: 120 and the job carries 125

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Looks good — well-structured feature with thoughtful security design. One logic clarity issue worth noting, but nothing blocking.


Findings

⚠️ Suggestions

  • agentic_pipeline.rs:627aggregate_approval_config silently takes the first non-empty instructions from the sorted tool list when multiple tools each carry their own instructions. The reviewer is shown only one tool's message while multiple approval-gated tools are pending. The code comment says "Documented in docs/safe-outputs.md", but authors who set per-tool instructions will likely be surprised that only one is shown. Consider a brief doc-comment on the function (or on ApprovalConfig::instructions) spelling out "first tool in alphabetical order wins" so the invariant is co-located with the implementation.

  • agentic_pipeline.rs:481filter_flags builds --exclude t1 --exclude t2 by appending one flag-value pair at a time. This is correct, but the existing comment ("leading space so it concatenates onto the fixed command") is easy to miss when reading the call-sites. The shell-safety rationale is well-documented, though it mirrors safety arguments already present in the surrounding code.

  • agentic_pipeline.rs:770detect_reviewed_proposals_step does find "{working_directory}/safe_outputs" -name "safe_outputs.ndjson" 2>/dev/null | head -n 1. If working_directory were to contain a double-quote character this would break the bash quoting, but that's a pre-existing pattern in execute_safe_outputs_step (same style for source_path) rather than a new concern here.

✅ What Looks Good

  • Fail-closed design throughout: on-timeout defaults to Reject, assert! in aggregate_approval_config enforces the non-empty-slice invariant (a security boundary, worth the always-on assert), and ManualReview's condition requires Succeeded && SafeToProcess == true && HasReviewedProposals == true so a flagged or failed Detection run can never open the gate.

  • validate_require_approval() as an eager guard: The .ok() paths in global_require_approval/tool_require_approval could silently drop a malformed config (e.g. on-timeout: rejec) and remove the tool from the reviewed list without warning. The eager validation step called in build_pipeline_context catches this class of "silent fail-open" regression before compilation proceeds. The regression test (test_validate_require_approval_rejects_bad_on_timeout) is exactly right.

  • Template-expression injection prevention: Blocking ${{ }} in approvers/notify_users while explicitly allowing $(...) runtime macros in instructions is the correct distinction for this ADO context, and the dedicated contains_ado_template_expression function keeps it narrow and auditable.

  • Pool::Server lowering: Emitting the scalar pool: server (not a mapping) is the correct ADO YAML syntax for agentless/server jobs. The 1ES templateContext skip for Pool::Server is correct and well-tested.

  • Test coverage: The unit tests in types.rs, the IR lowering test for pool: server, and the integration tests (test_mixed_approval_splits_execution_jobs, test_require_approval_timeout_bounds_task_not_just_job, the teardown dependency test, the audit aggregation test) cover the critical paths comprehensively.

Generated by Rust PR Reviewer for issue #1196 · 247.3 AIC · ⌖ 12.2 AIC · ⊞ 34.7K ·

Previously aggregate_approval_config took only the first non-empty instructions
from the sorted reviewed-tool list, so when multiple approval-gated tools each
carried their own note the reviewer saw just one and the rest were silently
dropped.

- compose_review_instructions now lists every reviewed tool and attaches ALL
  author-supplied per-tool notes (grouped when identical, e.g. inherited from a
  section-level require-approval). A single reviewed tool with its own
  instructions still shows that note verbatim.
- co-locate the invariant in a doc-comment on aggregate_approval_config /
  compose_review_instructions; update docs/safe-outputs.md (no more
  first-tool-wins)
- integration test asserting every tool is listed and all notes appear

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Looks good overall — the security-critical paths are well-guarded. Two minor issues worth addressing before merge.

Findings

⚠️ Suggestions

  • src/compile/agentic_pipeline.rsaggregate_approval_config uses assert! with a misleading comment

    assert!(
        !reviewed.is_empty(),
        "aggregate_approval_config called with no reviewed tools (would default to fail-open resume)"
    );

    The inline comment calls this "a release-build assert!", which reads like a debug_assert! (no-op in release). Rust's assert! always fires — debug_assert! is the release-stripped variant. The intent (keep the check in release for the security invariant) is correct; just the comment phrasing is wrong. Recommend updating the comment, or — more consistently with the project's anyhow::Result convention throughout — converting the function to return Result<ApprovalConfig> and replacing assert! with anyhow::ensure!. Not a bug in practice, but the comment currently says the opposite of what Rust does.

  • src/compile/agentic_pipeline.rsaggregate_approval_config timeout_minutes aggregation strategy is undocumented

    timeout_minutes = Some(timeout_minutes.map_or(t, |existing| existing.min(t)));

    When multiple reviewed tools configure different timeout-minutes values (e.g., one tool at 60, another at 120), the gate uses the minimum — the strictest bound governs. This is a reasonable conservative choice, but docs/safe-outputs.md doesn't mention the aggregation strategy and neither does the function's docstring. A user who configures a 60-minute timeout on a low-impact tool and 120 minutes on a high-impact one will find both governed by the 60-minute bound. Worth a one-liner in the timeout-minutes field docs.

✅ What Looks Good

  • Security guard on validate_require_approval: The upfront eager validation (called from build_pipeline_context) catches every malformed require-approval value — bad on-timeout, unknown fields, bad types — so the downstream .ok()-silent paths in global_require_approval/tool_require_approval can never silently disable a gate the author intended. The regression test test_validate_require_approval_rejects_bad_on_timeout directly exercises the rejec typo scenario. Well done.

  • ${{ ... }} template-expression rejection: validate_require_approval rejects ADO template expressions in approvers, notify-users, and instructions while deliberately allowing $(...) runtime macros in instructions. The split between contains_ado_template_expression (narrow) and contains_ado_expression (broad) is clean and the doc explains the rationale.

  • filter_flags injection safety: Tool names in --exclude/--only are spliced into bash without per-name quoting. The comment correctly points to validate::is_safe_tool_name via validate_safe_outputs_keys, run in build_pipeline_context before build_canonical_jobs reaches this function. The invariant is real.

  • detect_reviewed_proposals_step matching strategy: Using jq -r 'select(type=="object") | .name // empty' to extract only top-level name values prevents nested "name" keys in tool params from false-positiving. The grep fallback is intentionally over-match (pauses unnecessarily) rather than under-match (misses a required review). Correct failure mode.

  • Pool::Server 1ES skip: The onees_ir.rs guard (if job.pool == Pool::Server { continue; }) correctly avoids wrapping the agentless ManualReview job in a templateContext block that would suppress pool: server.

  • aggregate_approval_config fail-closed default: all_resume starts true and flips to false on any tool that is missing, absent an explicit Resume, or has None timeout. The only way the gate auto-approves on timeout is if every reviewed tool explicitly says on-timeout: resume. Conservative and correct.

  • Test coverage: Global/per-tool override, absent approval, detailed-object propagation, mixed auto+reviewed split, teardown skipping the reviewed job, multi-tool instruction aggregation, timeout-bounds-task-not-job, audit across split artifacts, Pool::Server scalar lowering, ToolFilter::allows — comprehensive.

Generated by Rust PR Reviewer for issue #1196 · 415.3 AIC · ⌖ 12.6 AIC · ⊞ 34.7K ·

…1235)

* feat(compile): render proposed safe outputs to a build summary tab

Add an always-on safe-outputs summary tab so reviewers (and observers on
non-elevated runs) can see what an agent proposed without opening raw
artifacts. For manual-review runs the pending-approval proposals are listed
first, and the default review message points approvers at the tab.

- New ado-script bundle approval-summary (scripts/ado-script/src/approval-summary/):
  parses safe_outputs.ndjson, renders per-tool tailored markdown (key fields +
  truncated body) with a generic scalar fallback, groups pending-approval
  proposals before automatic ones, and attaches it via task.uploadsummary.
- All agent-generated content is sanitized for markdown display (escaped,
  code fences neutralised, control chars stripped, truncated) so a proposal
  cannot forge UI or break the layout.
- Rendered at the END of the Agent job (not the Detection/threat-analysis
  stage), after collect_safe_outputs_step. Best-effort: failure is a warning,
  never fails the build or blocks the review gate.
- Bundle delivered to the Agent job via a new safe_outputs_summary_active flag
  on the ado-script extension, reusing the existing install/download (no
  double-download).
- Namespaced output base name (ado-aw-safe-outputs.md) so the ADO-derived
  summary-tab title never collides with a consumer/template-target tab.
- New uploadSummary() vso-logger helper; reviewed-tool list passed via env
  (never spliced into a shell command).

Tests: 22 vitest cases (parsing, grouping/ordering, per-tool detail, generic
fallback, sanitization of hostile content, env handling) + 3 Rust integration
tests (review / plain / no-safe-outputs placement) + bash-lint coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(compile): point the aggregated approval message at the summary tab

Builds on the manual-review instruction aggregation: when multiple gated tools
each carry notes, the composed ManualValidation message now also directs
reviewers to the 'ado-aw-safe-outputs' summary tab (which this branch adds) to
inspect the full proposed content before approving.

- compose_review_instructions multi-tool branch references the summary tab
- extend the multi-tool aggregation test to assert the tab pointer

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(compile): harden safe-outputs summary renderer per review

Address review feedback on #1235:

- render.ts: strip control/newline chars (not just backticks) from the tool
  name in the heading code span — parseProposals accepts any non-empty name, so
  a crafted "name": "foo\nbar" could otherwise break the heading
- render.ts: entity-encode & in sanitizeInline so an agent-supplied entity
  sequence (e.g. &lt;) is shown literally rather than decoded by the browser
- render.ts: add tailored TOOL_SPECS for the diagnostic outputs noop /
  report-incomplete / missing-tool / missing-data so their informative
  free-text (context/reason) gets a fenced body instead of a 300-char inline
  truncation
- agentic_pipeline.rs: move partition_safe_outputs_by_approval() inside the
  safe-output guard so it isn't computed when no safe outputs are configured
- compiler_tests.rs: anchor the job_block helper header match with a trailing
  newline so e.g. "Agent" can't match an "Agent_*" header
- TS tests for the name-strip, & encoding, and diagnostic specs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(compile): de-dup summary feature gate; entity-encode tags; robust job_block

Address review feedback on #1235:

- add FrontMatter::has_any_safe_output_tool() as the single source of truth for
  the safe-outputs-summary gate, used by BOTH collect_extensions (bundle
  download via safe_outputs_summary_active) and build_agent_job (step
  emission), so the two can no longer drift and emit a step whose bundle was
  never downloaded
- sanitizeInline: HTML-entity-encode < and > (and & first) instead of
  CommonMark backslash escapes, so values stay inert regardless of whether
  ADO's summary renderer is CommonMark-compliant
- job_block test helper: slice at the next "\n- job: " offset instead of
  rest[1..], removing the fixed byte-width assumption about the leading char

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(compile): use newline delimiter for AW_REVIEWED_TOOLS; document fence neutralization

Address review feedback on #1235:

- AW_REVIEWED_TOOLS is now newline-delimited (was comma): a comma can legally
  appear in an unrestricted YAML map key, so a comma delimiter could misparse
  such a key into the wrong section, whereas a newline cannot appear in a
  one-line map key. parseReviewed splits on newline to match. (is_safe_tool_name
  already rejects both via validate_safe_outputs_keys, so this is
  defense-in-depth.)
- sanitizeBlock: expand the comment to document why U+02BC fence neutralization
  is deliberate — it keeps the body visually faithful while guaranteeing no real
  backtick run survives, which is breakout-safe regardless of how ADO's
  undocumented renderer tokenises fences (a zero-width-separator approach would
  regress if the renderer strips the separator first)
- TS tests: newline split + comma-not-split coverage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(approval-summary): fix stale AW_REVIEWED_TOOLS delimiter comment

The module I/O contract still described AW_REVIEWED_TOOLS as comma-separated,
but it is newline-separated (parseReviewed splits on \n; the Rust side emits
reviewed.join("\n")). Correct the comment to match the code and rationale.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jamesadevine
jamesadevine merged commit 8e76df6 into main Jun 28, 2026
4 checks passed
@jamesadevine
jamesadevine deleted the feat/safe-output-manual-review branch June 28, 2026 22:31
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Looks good — the implementation is correct and the security model is sound. One API design note worth tracking.

Findings

⚠️ Suggestions

  • src/compile/types.rstool_requires_approval() implicitly depends on prior validate_require_approval() call

    global_require_approval() and the private tool_require_approval() both use .ok() to silently discard parse errors. A misconfigured on-timeout (or any unknown field — ApprovalConfig uses deny_unknown_fields) would return None, making the tool silently fall out of the reviewed list and skip the intended approval gate.

    The doc comment on validate_require_approval() correctly identifies this coupling, and build_pipeline_context always calls it before the .ok() paths matter. But since tool_requires_approval() is pub, a future caller outside the compilation pipeline could get silently incorrect results (no gate where one was intended).

    A safer API would propagate errors — e.g. have tool_requires_approval() return Result<Option<ApprovalConfig>> — but that's a larger refactor. At minimum, a # Panics / # Safety note on tool_requires_approval() reminding callers that validate_require_approval() must be called first would make this invariant visible at the call site.

✅ What Looks Good

  • Fail-closed by design throughout: aggregate_approval_config uses assert! (not debug_assert!) to guard the empty-reviewed-list case, ensuring the invariant holds in release builds. The on-timeout aggregation defaults to Reject unless every contributing config explicitly opts in to Resume. Both are exactly right for a security gate.

  • Shell injection prevention: Tool names embedded into the detect_reviewed_proposals_step regex alternation (reviewed.join("|")) are compiler-controlled identifiers constrained to [a-zA-Z0-9-] by validate::is_safe_tool_name, which is enforced before any bash generation. The comment in filter_flags correctly documents this invariant.

  • ADO template expression blocking in validate_require_approval: ${{...}} is correctly rejected in approvers, notify_users, and instructions. The intentional carve-out for $(...) runtime macros (useful for things like approvers: "$(Build.RequestedFor)") is well-motivated and author-controlled, not agent-controlled.

  • 1ES Pool::Server handling: The onees_ir.rs guard (if job.pool == Pool::Server { continue; }) correctly prevents the ManualReview agentless job from being wrapped in templateContext:, which would break the server pool emission. Good defensive check.

  • SafeOutputsVariant split logic: All three cases are correctly handled — no reviewed tools (unchanged), all reviewed (single job gated by ManualReview), and mixed (auto job + reviewed job running in parallel from Detection, with the reviewed job gated). The wire_explicit_dependencies wiring matches.

  • TypeScript rendering security: sanitizeInline and sanitizeBlock are thorough. The backtick-run substitution with U+02BC for fence escaping is the right approach — alternatives that keep real backticks (e.g. zero-width-space separation) could be stripped by a renderer before fence-scanning. The security tests in render.test.ts explicitly cover hostile tool names (backtick/newline injection) and table-cell/HTML-tag/fence escapes in field values.

Generated by Rust PR Reviewer for issue #1196 · 882.5 AIC · ⌖ 12.4 AIC · ⊞ 34.7K ·

@github-actions github-actions Bot mentioned this pull request Jun 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant