Skip to content

Unify audit with capture denial routing - #847

Merged
Richie Gomez (richiemsft) merged 10 commits into
mainfrom
unified-audit-capture
Aug 18, 2026
Merged

Unify audit with capture denial routing#847
Richie Gomez (richiemsft) merged 10 commits into
mainfrom
unified-audit-capture

Conversation

@richiemsft

@richiemsft Richie Gomez (richiemsft) commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

📖 Description

Unifies wxc-exec --audit with the processContainer.captureDenials
execution pipeline.

--audit synthesizes allow-mode capture with ETL retention and
permissiveLearningMode, then delegates backend selection and execution to
mxc_engine. Native PSEC/V2 capture remains preferred when available and
policy-compatible; legacy SBOX and AppContainer tiers use guarded WPR with
exact handle-attested process scope.

WPR records a host-wide source ETL inside protected elevated scratch. After the
sandbox process tree terminates, the guardian uses normalized FILETIME
selection and Windows Trace Relogger to create a second ETL containing only
supported Learning Mode events from the attested process generations. Analysis
and explicit retention both consume that filtered ETL. The host-wide source
never crosses the privilege boundary, and relogging failure transfers no trace.

Both native and guarded providers honor explicit retainEtl after a terminal
wait. Abandoned process handles discard the trace rather than exposing
incomplete output.

Audit post-processing consumes the canonical DenialsDocument returned by the
selected provider, relocates outputs to denials.json and trace.etl,
snapshots file-based source policies, and generates Adjusted_*.json without
decoding the ETL again. Truncated analysis preserves JSON, ETL, and the source
snapshot but does not generate an adjusted policy.

🔗 References

🔍 Validation

  • cargo test -p learning_mode_windows -p plm --lib
  • cargo clippy --workspace --all-targets -- -D warnings
  • Release builds of wxc-exec.exe and plm.exe
  • Multi-axis review with no High or Medium findings
  • Windows VM guarded-WPR capture with a concurrent unrelated sandbox:
    • retained ETL decoded successfully to the same 11 canonical denials;
    • the target denial was present in canonical JSON and the retained ETL;
    • the unrelated sandbox denial was absent from both;
    • WPR stopped cleanly with no recovery marker or PLM process remaining.

✅ Checklist

📋 Issue Type

  • Bug fix
  • Feature
  • Task

GitHub Actions runs the PR validation build automatically. The ADO pipeline
(MXC-PR-Build) is the Azure version of the PR pipeline, kept in parity with
the GitHub Actions build; it runs on merge to main, and Microsoft reviewers
with write access can trigger it on a PR with /azp run. See
docs/pull-requests.md.

If the dependency-feed-check check fails on a new dependency, the crate must
be added to the feed before the PR can pass. See
docs/pull-requests.md
for the steps.

Microsoft Reviewers: Open in CodeFlow

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@microsoft-github-policy-service microsoft-github-policy-service Bot added the Copilot-Instructions PR modifies Copilot instruction files (.github/copilot-instructions.md or .github/instructions/) label Aug 13, 2026
@richiemsft
Richie Gomez (richiemsft) requested a balanced review from Copilot August 13, 2026 22:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Unifies --audit with the existing captureDenials pipeline and adds guarded-WPR ETL retention.

Changes:

  • Routes audit capture through mxc_engine.
  • Adds guarded analysis-plus-ETL transfer.
  • Reuses canonical denials for audit artifact generation.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/host/plm/src/stop.rs Extracts canonical-denials post-processing.
src/host/plm/src/elevated.rs Adds analyzed trace transfer protocol.
src/host/plm/src/analysis.rs Returns the written denials document.
src/core/wxc/src/main.rs Routes audit through engine capture.
src/core/wxc/src/audit.rs Prepares and finalizes audit artifacts.
src/core/wxc/Cargo.toml Adds learning-mode dependency.
src/core/mxc_engine/src/run.rs Exposes audit runner resolution.
src/core/mxc_engine/src/lib.rs Re-exports audit resolver.
src/core/mxc_engine/src/guarded_capture.rs Enables PLM trace transfer.
src/Cargo.lock Records dependency update.
src/backends/appcontainer/common/src/guarded_capture.rs Extends guarded-capture interfaces.
src/backends/appcontainer/common/src/base_container_runner.rs Retains guarded ETL in BaseContainer.
src/backends/appcontainer/common/src/appcontainer_runner.rs Retains guarded ETL in AppContainer.
README.md Documents unified audit behavior.
docs/learning-mode/capabilities.md Updates capture and retention guidance.
.github/copilot-instructions.md Updates architecture documentation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/backends/appcontainer/common/src/appcontainer_runner.rs Outdated
Comment thread src/backends/appcontainer/common/src/base_container_runner.rs Outdated
Comment thread docs/learning-mode/capabilities.md Outdated
Comment thread src/core/wxc/src/audit.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/core/wxc/src/audit.rs:104

  • If the ETL move succeeds but the following denials move fails (for example, due to an I/O error after the preflight), finalize returns while capture.etlPath still points at the removed source. The retained trace is then present at trace.etl but undiscoverable through the emitted metadata. Update each metadata path immediately after its corresponding move so partial relocation failures still identify every preserved artifact.
    move_new_file(&source_etl, &final_etl)?;
    move_new_file(&source_denials, &final_denials)?;
    capture.output_path = final_denials.to_string_lossy().into_owned();
    capture.etl_path = Some(final_etl.to_string_lossy().into_owned());

src/backends/appcontainer/common/src/base_container_runner.rs:2708

  • Drop passes allow_retention = false, but the guarded branch still invokes stop_analyzed() and writes a denials document. Unlike the native branch above, an abandoned guarded capture therefore performs analysis and leaves an orphan unique JSON output with no observable metadata. Route the drop path through session.discard() and only analyze/write outputs after a terminal wait().
                .filter(|_| allow_retention);
            let exit_code = self.last_exit_code.unwrap_or(-1);
            let stop_result = match etl_path.as_deref() {
                Some(etl_path) => session.stop_analyzed_with_trace(etl_path),
                None => session.stop_analyzed(),
            };

src/backends/appcontainer/common/src/appcontainer_runner.rs:1688

  • Drop calls this method with allow_trace_transfer = false, but this branch then calls stop_analyzed() and proceeds to write the canonical JSON. Dropping an unwaited guarded sandbox therefore creates an orphan denials file even though no caller can observe its unique path, rather than discarding the capture as the new abandonment contract requires. Use GuardedCaptureSession::discard() on the drop path and skip JSON generation; reserve stop/analyze (and optional ETL transfer) for a terminal wait().
            let capture_result = match etl_path.as_deref() {
                Some(etl_path) => session.stop_analyzed_with_trace(etl_path),
                None => session.stop_analyzed(),
            };

src/host/plm/src/elevated.rs:1295

  • The trace has already been persisted by read_analysis_and_trace_response, but a subsequent guardian exit/wait failure makes this method return Err. Both callers infer transfer completion from the overall result being Ok, so this case leaves a retained ETL without captureDenialsError.etlPath. Preserve transfer completion independently (or reliably remove the persisted destination before returning the wait error) so every remaining ETL stays discoverable.
            let analysis = result?;
            wait_result?;
            Ok(analysis)

Base automatically changed from v2-wpr-capture-fallback to main August 17, 2026 16:21
Route wxc-exec --audit through captureDenials so native PSEC/V2 remains preferred and guarded WPR provides compatible analysis and ETL retention. Reuse canonical denials for adjusted policy generation and support retainEtl consistently across both capture providers.

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

Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216
Update repository guidance for captureDenials-backed audit routing and guarded retainEtl parity.

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

Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 16 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

src/host/plm/src/elevated.rs:1590

  • The ETL has already been persisted when result is Ok, but a subsequent child wait failure makes this method return Err. Both runners infer etl_was_transferred from stop_result.is_ok(), so this path leaves a retained ETL on disk without publishing captureDenialsError.etlPath. Preserve transfer completion independently (or reliably remove the destination) when reporting the child-exit error.
            let wait_result = wait_for_child_exit(
                self.process.0,
                deadline.saturating_duration_since(Instant::now()),
            );
            let analysis = result?;
            wait_result?;
            Ok(analysis)

src/core/wxc/src/audit.rs:104

  • If the ETL move succeeds but the following denials move fails, metadata still points to the now-missing source ETL because both fields are updated only after both moves. Update each metadata path immediately after its corresponding successful move so every partial-failure pointer remains valid.
    move_new_file(&source_etl, &final_etl)?;
    move_new_file(&source_denials, &final_denials)?;
    capture.output_path = final_denials.to_string_lossy().into_owned();
    capture.etl_path = Some(final_etl.to_string_lossy().into_owned());

docs/learning-mode/capabilities.md:250

  • This new guarded-retention behavior leaves the public documentation contradictory: docs/schema.md:94-95 still says guarded WPR rejects retention, and docs/process-container/os-version-support.md:95-96 says raw ETL never crosses into the SDK result. Update those behavior-defining references in this PR so users do not receive incompatible contracts.
for diagnostics after a terminal wait. Both native PSEC/V2 capture and the
guarded-WPR fallback honor retention; guarded WPR transfers the sealed trace
back to the unelevated caller after process-scoped analysis. Native retention
begins under `%LOCALAPPDATA%\Microsoft\MXC\capture-denials\working` and moves
to a protected per-run directory under `capture-denials\retained` only after
sealing succeeds. Guarded WPR writes the retained ETL beside its unique
denials JSON output, using the same file stem with an `.etl` extension.

Comment thread src/host/plm/src/elevated.rs
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216
Copilot AI review requested due to automatic review settings August 17, 2026 17:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

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

Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216
Copilot AI review requested due to automatic review settings August 17, 2026 17:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

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

Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216
Copilot AI review requested due to automatic review settings August 17, 2026 17:45
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216
@richiemsft
Richie Gomez (richiemsft) marked this pull request as ready for review August 17, 2026 17:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 3 comments.

Suppressed comments (2)

src/core/wxc/src/audit.rs:104

  • These moves are not transactional: if the ETL move succeeds but the denials move fails (for example, during the copy fallback), finalize returns while metadata still points to the now-missing ETL source. Update each metadata path immediately after its corresponding successful move so the emitted pointer remains usable on a partial failure.
    move_new_file(&source_etl, &final_etl)?;
    move_new_file(&source_denials, &final_denials)?;
    capture.output_path = final_denials.to_string_lossy().into_owned();
    capture.etl_path = Some(final_etl.to_string_lossy().into_owned());

src/host/plm/src/elevated.rs:1591

  • The ETL has already been fully persisted when result is Ok, but a subsequent guardian wait timeout/nonzero exit makes this method return Err. Both runner callers currently infer transfer completion from stop_result.is_ok(), so this leaves a valid retained ETL on disk without captureDenialsError.etlPath. Return transfer completion separately from the guardian-exit result (or otherwise ensure callers advertise or remove a successfully persisted trace).
            let analysis = result?;
            wait_result?;
            Ok(analysis)

Comment thread src/backends/appcontainer/common/src/appcontainer_runner.rs Outdated
Comment thread src/backends/appcontainer/common/src/base_container_runner.rs Outdated
Comment thread src/host/plm/src/elevated.rs Outdated
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216
Copilot AI review requested due to automatic review settings August 17, 2026 18:04
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/core/wxc/src/audit.rs:104

  • The two moves are not transactional. If moving the ETL succeeds but moving the JSON fails, the metadata still points to the old ETL path, so audit failure output leaves the relocated trace undiscoverable. Update each metadata path immediately after its corresponding successful move.
    move_new_file(&source_etl, &final_etl)?;
    move_new_file(&source_denials, &final_denials)?;
    capture.output_path = final_denials.to_string_lossy().into_owned();
    capture.etl_path = Some(final_etl.to_string_lossy().into_owned());

src/backends/appcontainer/common/src/capture_output.rs:180

  • This only recognizes the literal .etl extension, but outputPath accepts Win32 aliases. For example, denials.etl::$DATA produces JSON denials.<run>.etl::$DATA and retained ETL denials.<run>.etl, which are the same default data stream; .etl has the same trailing-space collision. The trace transfer then occupies the JSON target and JSON creation fails. Normalize Win32 trailing-dot/space and ::$DATA aliases when deriving the pair, or reject such configured paths.
    let retained_name = match extension {
        Some(ext) if ext.eq_ignore_ascii_case("etl") => {
            format!("{stem}.{run_id}.trace.etl")
        }
        Some(_) => format!("{stem}.{run_id}.etl"),
        None => format!("{file_name}.{run_id}.etl"),

src/host/plm/src/elevated.rs:1590

  • read_analysis_and_trace_response has already persisted trace_destination before this wait. If the guardian then times out or exits nonzero, wait_result? returns an error; both runner callers currently derive etl_was_transferred from Result::is_ok(), so they omit captureDenialsError.etlPath even though the retained ETL remains on disk. Preserve transfer completion independently in the return contract, or remove/report the persisted destination on this post-transfer failure path.
        }
    }

Copilot AI review requested due to automatic review settings August 17, 2026 18:12
@richiemsft

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/core/wxc/src/audit.rs:104

  • These two moves are not atomic. If the ETL move succeeds but the denials move fails (for example due to an I/O or AV error), finalize returns before either metadata field is rewritten, leaving etlPath pointing to the now-missing source while the trace exists at trace.etl. Update each metadata path immediately after its corresponding successful move, or roll the first move back when the second fails.
    move_new_file(&source_etl, &final_etl)?;
    move_new_file(&source_denials, &final_denials)?;
    capture.output_path = final_denials.to_string_lossy().into_owned();
    capture.etl_path = Some(final_etl.to_string_lossy().into_owned());

src/host/plm/src/elevated.rs:1585

  • If the ETL is received and persisted successfully but wait_for_child_exit then fails, this method returns Err even though trace_destination still exists. Both guarded runners infer transfer completion with stop_result.is_ok(), so they omit captureDenialsError.etlPath and leave a retained trace that callers cannot discover or clean up. Preserve the transfer-completed state independently of the guardian wait result (or reliably remove the persisted destination before returning the error).
            let analysis = result?;
            wait_result?;
            Ok(analysis)

src/backends/appcontainer/common/src/capture_output.rs:180

  • The paired names are still not guaranteed to be distinct for accepted Windows aliases. For example, outputPath: "C:\\logs\\denials.etl::$DATA" produces JSON at denials.<run-id>.etl::$DATA and the retained trace at denials.<run-id>.etl; ::$DATA denotes that same unnamed stream, so transfer creates the JSON target and the later create_new write always fails. Trailing-space/dot variants of .etl have the same problem. Reject/normalize these aliases or derive an ETL name that remains distinct under Windows target semantics, not just string extension comparison.
    let retained_name = match extension {
        Some(ext) if ext.eq_ignore_ascii_case("etl") => {
            format!("{stem}.{run_id}.trace.etl")
        }
        Some(_) => format!("{stem}.{run_id}.etl"),
        None => format!("{file_name}.{run_id}.etl"),

Comment thread src/core/wxc/src/main.rs Outdated
Comment thread src/host/plm/src/elevated.rs
Comment thread src/backends/learning_mode/windows/src/etl_filter.rs Outdated
Comment thread src/core/wxc/src/audit.rs Outdated
Comment thread src/backends/appcontainer/common/src/appcontainer_runner.rs Outdated
Comment thread src/backends/appcontainer/common/src/appcontainer_runner.rs Outdated
Comment thread src/core/wxc/src/audit.rs Outdated
Comment thread src/core/mxc_engine/src/run.rs Outdated
Comment thread src/backends/learning_mode/windows/src/etl_filter.rs Outdated
Comment thread src/host/plm/src/elevated.rs
Comment thread src/host/plm/readme.md Outdated
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: f9ac49b1-fdfe-4cca-9412-9faa99cef2ab
Copilot AI review requested due to automatic review settings August 18, 2026 16:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 22 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/backends/learning_mode/windows/src/etl_decode.rs:621

  • This converts the analyzer's normal truncation boundary into a hard capture failure. On the 1,000,001st in-scope event, selection returns Err before relogging, so guarded capture emits neither the truncated JSON nor the retained ETL promised for truncated analysis. Keep one overflow event in the filtered trace so the downstream analyzer can set deniedResourcesTruncated, then omit later in-scope events while continuing the count-reconciliation pass.
            if acc.relog_selected_event_indices.len() >= MAX_PROCESSED_EVENTS {
                acc.decode_error = Some(format!(
                    "trace exceeded the {MAX_PROCESSED_EVENTS}-event process-scoped relogging limit"
                ));
                acc.stop_requested = true;

src/backends/appcontainer/common/src/capture_output.rs:200

  • The generated paths are still not distinct for every accepted Windows filename. For example, outputPath: "C:\\logs\\denials.etl::$DATA" passes the parser; the JSON path becomes denials.<run-id>.etl::$DATA (the unnamed stream of denials.<run-id>.etl), while this branch chooses denials.<run-id>.etl for the trace. The trace is persisted first, then the JSON create_new necessarily collides with that same file. Either reject alternate-stream syntax during validation or derive the ETL from an independent filename that cannot alias the configured target.
    let retained_name = match extension {
        Some(ext) if ext.eq_ignore_ascii_case("etl") => {
            format!("{stem}.{run_id}.trace.etl")
        }
        Some(_) => format!("{stem}.{run_id}.etl"),
        None => format!("{file_name}.{run_id}.etl"),
    };

@richiemsft

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@richiemsft
Richie Gomez (richiemsft) merged commit ff01b57 into main Aug 18, 2026
38 of 42 checks passed
@richiemsft
Richie Gomez (richiemsft) deleted the unified-audit-capture branch August 18, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Copilot-Instructions PR modifies Copilot instruction files (.github/copilot-instructions.md or .github/instructions/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants