Skip to content
Open
8 changes: 6 additions & 2 deletions src/core/wxc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ nanvix_runner = { workspace = true, optional = true }
nanvix_binaries = { path = "../../backends/nanvix/binaries", optional = true }
wslc_common = { workspace = true, optional = true }
isolation_session_bindings = { workspace = true, optional = true }
# Shared PLM dep for the singleton env-var name and `wait_until_cleared`
# helper β€” single source of truth so the two crates can't drift.
# Shared PLM dep: `wpr_command` (safe wpr.exe resolver used for
# ctrl-c cleanup), `CTRL_HANDLER_DRAIN_TIMEOUT`, `wait_until_cleared`,
# and the `singleton::*` primitives β€” single source of truth so the
# two crates can't drift. The `--wxc-singleton-held-by-parent` handoff
# is a CLI arg on `plm.exe`, not an env var, because ShellExecuteExW
# with `runas` drops the caller's env block on the way through UAC.
plm = { path = "../../host/plm" }
tempfile.workspace = true

Expand Down
31 changes: 6 additions & 25 deletions src/core/wxc/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,11 @@ pub fn plm_exe_path() -> Option<std::path::PathBuf> {
.and_then(|p| p.parent().map(|d| d.join("plm.exe")))
}

/// Run `plm.exe <subcommand> <args...>` synchronously and route stdio
/// through to wxc-exec's console. Audit tracing is a best-effort
/// Run `plm.exe <subcommand> <args...>` synchronously via
/// `run_plm_elevated`, which captures the child's stdout/stderr into
/// temp files (the UAC broker can't inherit our stdio) and replays
/// them only on non-zero exit or when `verbose` is set β€” the happy
/// path is deliberately silent. Audit tracing is a best-effort
/// diagnostic: missing-binary / spawn / non-zero-exit conditions are
/// logged and returned as `false` β€” this function never calls
/// `process::exit` on its own. The caller (currently the `--audit`
Expand Down Expand Up @@ -185,36 +188,14 @@ pub fn mark_audit_active() {
/// applies the same hardening in its own resolver.
pub fn cancel_active_audit_trace() {
if AUDIT_ACTIVE.swap(false, Ordering::SeqCst) {
let wpr = resolve_system32_wpr();
let _ = std::process::Command::new(&wpr)
let _ = plm::wpr_path::wpr_command()
.arg("-cancel")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}

/// Resolve `<System32>\wpr.exe` via `GetSystemDirectoryW`. Reading
/// `%SystemRoot%` from the process env is unsafe because UAC inherits
/// env from the unelevated parent β€” a standard user could `setx
/// SystemRoot=C:\\Users\\Public\\evil` and plant `wpr.exe` for a later
/// admin run. `GetSystemDirectoryW` is kernel-published and not
/// env-spoofable.
fn resolve_system32_wpr() -> std::path::PathBuf {
use windows::Win32::System::SystemInformation::GetSystemDirectoryW;
let mut buf = vec![0u16; 260];
// SAFETY: buf is initialized; we pass valid length and own the
// memory for the duration of the call.
let n = unsafe { GetSystemDirectoryW(Some(&mut buf)) };
if n == 0 || (n as usize) > buf.len() {
return std::path::PathBuf::from("C:\\Windows\\System32\\wpr.exe");
}
let dir = wxc_common::string_util::from_wide(&buf[..n as usize]);
let mut p = std::path::PathBuf::from(dir);
p.push("wpr.exe");
p
}

/// Stack-owned guard: ensures the audit trace is cancelled on panic
/// unwind and on normal function return.
pub struct AuditTraceGuard;
Expand Down
3 changes: 0 additions & 3 deletions src/core/wxc/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,9 +343,6 @@ fn config_file_path(cli: &Cli) -> Option<std::path::PathBuf> {
.map(std::path::PathBuf::from)
}

/// Path to `plm.exe`, expected to sit next to `wxc-exec.exe` in the
/// same install directory. Returns `None` when the current exe path
/// can't be resolved.
#[cfg(target_os = "windows")]
use audit::{
cancel_active_audit_trace, mark_audit_active, release_audit_singleton, run_plm_command,
Expand Down
8 changes: 4 additions & 4 deletions src/core/wxc_common/src/config_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,8 +709,8 @@ fn convert_wire_config(
.capabilities
.push("permissiveLearningMode".to_string());
logger.log("WARNING: 'learningMode' enabled - AppContainer restrictions will NOT be enforced (DEBUG BUILD ONLY)\n");
eprintln!(
"[mxc] permissiveLearningMode injected via 'learningMode: true' - AppContainer restrictions are NOT enforced"
logger.log(
"[mxc] permissiveLearningMode injected via 'learningMode: true' - AppContainer restrictions are NOT enforced\n",
);
}
#[cfg(not(debug_assertions))]
Expand All @@ -722,8 +722,8 @@ fn convert_wire_config(
if let Some(caps) = ac.capabilities {
#[cfg(debug_assertions)]
if caps.iter().any(|c| c == "permissiveLearningMode") {
eprintln!(
"[mxc] permissiveLearningMode present in policy capabilities - AppContainer restrictions are NOT enforced"
logger.log(
"[mxc] permissiveLearningMode present in policy capabilities - AppContainer restrictions are NOT enforced\n",
);
}
policy.capabilities.extend(caps);
Expand Down
39 changes: 22 additions & 17 deletions src/host/plm/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,33 @@

`plm.exe` is the Windows-only trace driver for permissive learning mode. Long-form, it captures the access-denied events emitted by Windows' permissive sandbox layer, decodes them into structured findings, and merges those findings back into an MXC container config so the next enforcing run succeeds.

This PR introduces the **trace-lifecycle skeleton only**: WPR start/stop, the host-wide singleton mutex, the embedded `plm.wprp` materializer, and the `wxc-exec --audit` plumbing. Event parsing, capability extraction, filesystem/UI merging, and the adjusted-config writer arrive in subsequent PRs.
This PR introduces **filesystem extraction**: `EventID=14` records are walked from the captured `.etl`, file paths are extracted and merged into `filesystem.{readwritePaths,readonlyPaths}` on a copy of the input config. The host-wide singleton mutex, embedded `plm.wprp` materializer, and `wxc-exec --audit` plumbing landed in the previous PR. Capability extraction, UI relaxation, and the `Adjusted_*.json` writer arrive in subsequent PRs.

PLM is invoked automatically by [`wxc-exec --audit`](../../../README.md#audit-mode-permissive-learning-mode); the standalone CLI documented here is for capturing traces, interactive iteration, and (later) debugging the parser itself.
PLM is invoked automatically by [`wxc-exec --audit`](../../../README.md#audit-mode-permissive-learning-mode); the standalone CLI documented here is for capturing traces, interactive iteration, and debugging the parser itself.

## How it works (skeleton)
## How it works

1. **Capture** β€” `plm start` calls `wpr -start <plm.wprp>!AccessFailureProfile -filemode`, enabling the `Microsoft-Windows-Privacy-Auditing-PermissiveLearningMode` and `Microsoft-Windows-Kernel-General` ETW providers in a secure realtime collector.
2. **Run** β€” the operator runs the workload. The OS-side permissive sandbox logs `EventID=14` / `EventID=27` for every access that *would* have been denied.
3. **Stop** β€” `plm stop` calls `wpr -stop <trace.etl>` and records the captured trace location.
4. **Parse / Merge** β€” *(arrives in later PRs)* the `.etl` is walked with `EvtQuery` / `EvtRender` and findings are merged into a copy of the input config as `Adjusted_<name>.json`.
3. **Stop** β€” `plm stop` calls `wpr -stop <trace.etl>` and walks the `.etl` with `EvtQuery` / `EvtRender`.
4. **Parse** β€” for each `EventID=14`, the parser pulls the file path / access mask. Capability ACE-blob decoding lands in a later PR; `EventID=27` UI relaxation lands in a later PR.
5. **Merge** β€” file paths are added to `filesystem.readwritePaths` / `filesystem.readonlyPaths` on a copy of the input config. The `Adjusted_*.json` writer arrives in the next PR; this PR only prints the per-event summary.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The README still describes the blank-config preview as future work in three places, but it is already implemented and active in this PR.

Line 15 says "The Adjusted_*.json writer arrives in the next PR; this PR only prints the per-event summary." Line 61 says "The diff against a blank config preview arrives in later PRs." Both are stale. log.rs (lines 85-108) already merges extracted paths into a blank config and prints the resulting JSON as the actual current output of plm log, not a future feature.

Line 24's table entry for src/log.rs, "Interactive mode: Enter to start, Enter to stop, then diff vs a blank config," is also inconsistent with the surrounding text since it describes the diff as already happening while line 61 says it is still future work.

Fix: Update the README to document the current "blank config after merge" output that log.rs actually produces today, and remove the stale future-work wording in both places. The Adjusted_*.json writer claim in line 15 is a separate, still-accurate future-work statement (see the CLI help finding for main.rs) and should stay, but should not be conflated with the blank-config preview, which is already shipped.


## Layout (this PR)

| File | Role |
|-----------------------|-------------------------------------------------------------------------------------|
| `src/main.rs` | `clap` dispatch for `plm start` / `plm stop` / `plm log` (`extract-caps` lands later) |
| `src/start.rs` | `wpr -cancel` (best-effort) + `wpr -start …!AccessFailureProfile -filemode` |
| `src/stop.rs` | `wpr -stop` (or skip with `--trace-file`); parse + merge arrive in later PRs |
| `src/log.rs` | Interactive mode: Enter to start, Enter to stop; preview arrives in later PRs |
| `src/coordination.rs` | Cross-process singleton named-mutex + bypass-env-var coordination for `plm log` |
| `src/wpr_path.rs` | Resolves `wpr.exe` to its absolute `%SystemRoot%\System32` path (PATH-spoof-safe) |
| `src/profile_gen.rs` | Inline WPR profile (`EMBEDDED_WPRP`) + run-time writer that drops `plm.wprp` next to `plm.exe` when missing |
| File | Role |
|-------------------------|-----------------------------------------------------------------------------------|
| `src/main.rs` | `clap` dispatch for `plm start` / `plm stop` / `plm log` (`extract-caps` lands later) |
| `src/start.rs` | `wpr -cancel` (best-effort) + `wpr -start …!AccessFailureProfile -filemode` |
| `src/stop.rs` | `wpr -stop` (or skip with `--trace-file`) + parse + FS merge |
| `src/log.rs` | Interactive mode: Enter to start, Enter to stop, then diff vs a blank config |
| `src/event_parser.rs` | `EvtQuery` / `EvtRender` walk; shared `ParseAccumulator` + per-event dispatcher |
| `src/access_failure.rs` | `EventID=14` decoder: file-path normalization, post-XPath filters |
| `src/access_event.rs` | `LearningModeAccessEvent` plain struct |
| `src/config.rs` | JSON load/mutate; path merge into `filesystem.{readwritePaths,readonlyPaths}` |
| `src/coordination.rs` | Cross-process singleton named-mutex + bypass-env-var coordination for `plm log` |
| `src/wpr_path.rs` | Resolves `wpr.exe` to its absolute `%SystemRoot%\System32` path (PATH-spoof-safe) |
| `src/profile_gen.rs` | Inline WPR profile (`EMBEDDED_WPRP`) + run-time writer that drops `plm.wprp` next to `plm.exe` when missing |

## CLI

Expand All @@ -49,7 +54,7 @@ plm.exe stop [--config-path <path>] [--log-dir <path>] [--bin-path <path>]
[--verbose-logging]
```

`--config-path` / `--adjusted-config-path` are accepted today so `wxc-exec --audit` can pass them through; the merge that consumes them arrives in subsequent PRs.
`--config-path` drives an in-memory filesystem merge against the input config; the `Adjusted_*.json` writer that persists it arrives in the config-generation PR. `--adjusted-config-path` is accepted today so `wxc-exec --audit` can pass it through.

### `plm log`

Expand All @@ -75,9 +80,9 @@ The WPR profile is embedded into `plm.exe` itself (see `src/profile_gen.rs`); on
## Limitations

- **Windows-only.** Uses `wpr.exe` and Job-Object UI-limit semantics that have no portable equivalent.
- **No parse-and-merge yet.** `plm stop` writes the captured `.etl` to the log directory but does not yet produce an `Adjusted_*.json`. Later PRs add file-path extraction, capability extraction, UI-policy extraction, and the adjusted-config writer.
- **No adjusted-config writer yet.** `plm stop` merges discovered paths into an in-memory copy of the input config and prints the per-event summary, but does not yet write an `Adjusted_*.json`. Later PRs add the adjusted-config writer, capability extraction, and UI-policy extraction.

## See also

- [`docs/base-process-container/guide.md`](../../../docs/base-process-container/guide.md) β€” process-container backend overview
- [`docs/process-container/guide.md`](../../../docs/process-container/guide.md) β€” process-container backend overview
- [README β†’ Debugging β†’ Audit Mode](../../../README.md#audit-mode-permissive-learning-mode) β€” `wxc-exec --audit` integration
19 changes: 19 additions & 0 deletions src/host/plm/src/access_event.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//! Port of the `LearningModeAccessEvent` PowerShell class from
//! `stop_plm_logging.ps1`. One file-access record from a PLM WPR trace.

use chrono::{DateTime, Utc};

#[derive(Debug, Clone)]
// Fields kept for parity with the PowerShell version even though the
// orchestrator currently only uses file_path and access_mask.
#[allow(dead_code)]
pub struct LearningModeAccessEvent {
Comment on lines +7 to +10

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

question: Are all these needed now that the powershell version is no more? Or are they gonna be used in a future PR?

pub time_created: DateTime<Utc>,
pub process_id: u32,
pub thread_id: u32,
pub learning_mode: String, // Permissive/Enforcing
pub resource_type: String, // File/Directory
pub file_path: String,
pub app_path: String,
pub access_mask: u32,
}
Loading
Loading