feat(compile): gate high-impact safe outputs behind manual review - #1196
Conversation
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>
🔍 Rust PR ReviewSummary: 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
Both .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
The 🔒 Security ConcernsNone beyond the silent-failure issue above (which is correctness as much as security).
|
…al-review # Conflicts: # AGENTS.md
…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>
🔍 Rust PR ReviewSummary: Looks good overall — well-architected feature with strong security defaults and thorough tests. Two issues worth addressing. Findings🐛 Bugs / Logic Issues
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 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 In the mixed split scenario (some auto, some reviewed tools), 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
|
…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>
50e7841 to
d552c82
Compare
🔍 Rust PR ReviewSummary: 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
✅ What Looks Good
|
…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>
🔍 Rust PR ReviewSummary: Looks good — the implementation is solid and security-conscious. Four minor observations worth considering before merge. Findings
|
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>
🔍 Rust PR ReviewSummary: Looks good — well-structured feature with strong security defaults and comprehensive tests. Two minor concerns worth noting. Findings🔒 Security Concerns
|
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>
🔍 Rust PR ReviewSummary: Nearly production-ready — one correctness bug with Findings🐛 Bugs / Logic Issues
✅ What Looks Good
|
… 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>
🔍 Rust PR ReviewSummary: Looks good — well-structured feature with thoughtful security design. One logic clarity issue worth noting, but nothing blocking. Findings
|
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>
🔍 Rust PR ReviewSummary: Looks good overall — the security-critical paths are well-guarded. Two minor issues worth addressing before merge. Findings
|
…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. <) 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>
🔍 Rust PR ReviewSummary: Looks good — the implementation is correct and the security model is sound. One API design note worth tracking. Findings
|
Summary
Wires the previously-unused
ManualValidation@1builder 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 existingsafe-outputs:map, matching current conventions:require-approvalaccepts a bare boolean or an object for finer control:Resolution / defaults: per-tool
require-approval> section-level > unset (false). A barerequire-approval: truepauses the run, lets anyone with run permission approve/reject, sends no emails, and fails closed on timeout.Compiled pipeline shape
ManualReviewjob (pool: server,ManualValidation@1) inserted between Detection and SafeOutputs.SafeOutputsjob depends only on Agent + Detection (--excludethe reviewed tools) and runs in parallel with the review pause, independent of the approval outcome.SafeOutputs_Reviewedjob (--onlythe reviewed tools), gated behindManualReview, publishing a distinctsafe_outputs_reviewedartifact.HasReviewedProposals) — no pointless pauses.Implementation notes
RequireApproval/ApprovalConfigtypes + per-tool resolution (types.rs); the reservedrequire-approvalkey is filtered everywheresafe-outputs:keys are treated as tool names (common.rs,main.rs).Pool::Serveragentless IR variant emitting the scalarpool: server(ir/job.rs,ir/lower.rs,ir/summary.rs); 1ES path skips thetemplateContextwrap for server jobs (onees_ir.rs).ado-aw executegains--only/--excludetool filters (execute.rs,main.rs).audit/analyzers/safe_outputs.rs).standalone,1es,job,stage).Test plan
cargo buildcargo clippy --all-targets --all-features— cleancargo test— 2237 unit tests + all integration suites greencargo test --test bash_lint_tests— shellcheck passes on the new bash bodiesToolFilter, conditional pause (HasReviewedProposals), and audit aggregation across split artifactsrequire-approval)