Skip to content

Commit 720dc0f

Browse files
committed
Refactor controller into focused modules
Split controller logic into dedicated modules with doc comments, relocate runner selector tests, and update controller README to match the new layout.
1 parent 339af59 commit 720dc0f

18 files changed

+2572
-2333
lines changed

controller/README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,26 @@ For the Swift runner implementation details, see `runner/README.md`.
1010

1111
## What lives in `controller/`
1212

13-
- `controller/src/main.rs` — CLI parsing and specimen evaluation orchestration
14-
- `controller/src/json_contract.rs` — JSON key sorting helper (used by other bins)
13+
Core controller modules:
14+
15+
- `controller/src/main.rs` — entry point and module wiring
16+
- `controller/src/cli.rs` — CLI usage text + top-level dispatch
17+
- `controller/src/run_flow.rs` — run orchestration and JSON envelope assembly
18+
- `controller/src/runner_select.rs` — runner selection + provenance
19+
- `controller/src/runner_client.rs` — wrapper around `pw-runner-client`
20+
- `controller/src/sandbox_log.rs` — unified-log capture mapping for sandbox denials
21+
- `controller/src/sonoma_cross_check.rs``sb_api_validator` cross-check flow
22+
- `controller/src/runner_commands.rs` — external runner install/list/verify/remove/refresh
23+
24+
Support modules:
25+
26+
- `controller/src/app_layout.rs` — app bundle layout + embedded tool resolution
27+
- `controller/src/plist.rs` — PlistBuddy helpers for Info.plist lookups
28+
- `controller/src/bundle.rs` — bundle metadata reader for external runners
29+
- `controller/src/request_patch.rs` — request JSON injection helpers
30+
- `controller/src/utils.rs` — shared time + output helpers
31+
- `controller/src/evidence.rs` — evidence manifest parsing + verification
32+
- `controller/src/json_contract.rs` — JSON envelope rendering with sorted keys
1533
- `controller/src/runner_manager.rs` — external runner registry + launchd wiring
1634

1735
Standalone helper tools (embedded into the `.app`):

controller/src/app_layout.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//! Bundle layout helpers for PolicyWitness.app.
2+
//!
3+
//! The controller resolves embedded tools relative to its own executable so the
4+
//! app bundle can be relocated without rewriting paths.
5+
6+
use std::path::{Component, Path, PathBuf};
7+
8+
use crate::plist::plist_key_string;
9+
10+
pub const PW_RUNNER_SERVICE_DIR: &str = "PWRunner";
11+
12+
#[derive(Clone)]
13+
pub struct PWRunnerBundleInfo {
14+
pub bundle_id: String,
15+
pub executable: String,
16+
}
17+
18+
pub fn validate_tool_name(tool_name: &str) -> Result<(), String> {
19+
let mut components = Path::new(tool_name).components();
20+
match (components.next(), components.next()) {
21+
(Some(Component::Normal(_)), None) => Ok(()),
22+
_ => Err(format!(
23+
"invalid tool name {tool_name:?} (must be a single path component)"
24+
)),
25+
}
26+
}
27+
28+
pub fn app_root_from_current_exe() -> Result<PathBuf, String> {
29+
let exe = std::env::current_exe().map_err(|e| format!("current_exe() failed: {e}"))?;
30+
// Expected layout: PolicyWitness.app/Contents/MacOS/policy-witness
31+
let contents_dir = exe
32+
.parent()
33+
.and_then(|p| p.parent())
34+
.ok_or_else(|| format!("unexpected executable location: {}", exe.display()))?;
35+
let app_root = contents_dir
36+
.parent()
37+
.ok_or_else(|| format!("unexpected executable location: {}", exe.display()))?;
38+
Ok(app_root.to_path_buf())
39+
}
40+
41+
pub fn resolve_contents_macos_tool(tool_name: &str) -> Result<PathBuf, String> {
42+
validate_tool_name(tool_name)?;
43+
let exe = std::env::current_exe().map_err(|e| format!("current_exe() failed: {e}"))?;
44+
let contents_dir = exe
45+
.parent()
46+
.and_then(|p| p.parent())
47+
.ok_or_else(|| format!("unexpected executable location: {}", exe.display()))?;
48+
// Tools live alongside the controller under Contents/MacOS.
49+
let candidate = contents_dir.join("MacOS").join(tool_name);
50+
if candidate.exists() {
51+
return Ok(candidate);
52+
}
53+
Err(format!(
54+
"embedded tool not found in Contents/MacOS: {tool_name:?} (expected: {})",
55+
candidate.display()
56+
))
57+
}
58+
59+
pub fn resolve_pw_runner_bundle_info(app_root: &Path) -> Result<PWRunnerBundleInfo, String> {
60+
let plist = app_root
61+
.join("Contents")
62+
.join("XPCServices")
63+
.join(format!("{PW_RUNNER_SERVICE_DIR}.xpc"))
64+
.join("Contents")
65+
.join("Info.plist");
66+
if !plist.exists() {
67+
return Err(format!("missing PWRunner Info.plist: {}", plist.display()));
68+
}
69+
let bundle_id = plist_key_string(&plist, "CFBundleIdentifier")?;
70+
let executable = plist_key_string(&plist, "CFBundleExecutable")?;
71+
Ok(PWRunnerBundleInfo {
72+
bundle_id,
73+
executable,
74+
})
75+
}

controller/src/bin/sandbox-log-observer.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
//! Unified-log observer for sandbox deny events.
2+
//!
3+
//! This tool runs outside the app sandbox to query the system log for deny
4+
//! messages associated with a specific process. It emits JSON so the controller
5+
//! can attach evidence without parsing raw log output.
6+
17
#[path = "../json_contract.rs"]
28
#[allow(dead_code)]
39
mod json_contract;
@@ -75,6 +81,7 @@ fn json_result(ok: bool) -> json_contract::JsonResult {
7581

7682
fn is_pid_alive(pid: i32) -> bool {
7783
unsafe {
84+
// kill(pid, 0) checks existence without delivering a signal.
7885
if kill(pid, 0) == 0 {
7986
return true;
8087
}
@@ -89,6 +96,7 @@ fn is_pid_alive(pid: i32) -> bool {
8996
fn sandbox_predicate(process_name: &str, pid: i32) -> String {
9097
let term = format!("Sandbox: {}({})", process_name, pid);
9198
let escaped = term.replace('"', "\\\"");
99+
// Match both explicit deny lines and any sandbox line for the PID.
92100
format!(
93101
r#"((eventMessage CONTAINS[c] "{}") OR ((eventMessage CONTAINS[c] "deny") AND (eventMessage CONTAINS[c] "{}")))"#,
94102
escaped, pid
@@ -518,6 +526,7 @@ fn main() {
518526
let mut log_error: Option<String> = None;
519527
let mut blocked_reason: Option<String> = None;
520528

529+
// log stream gives live updates; log show is a point-in-time snapshot.
521530
let mode = if stream_mode { "stream" } else { "show" }.to_string();
522531
let duration_ms = duration.map(|d| d.as_millis() as u64);
523532

controller/src/bundle.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//! Bundle metadata helpers for external runners.
2+
//!
3+
//! External XPC services are packaged as bundles with Info.plist metadata; we
4+
//! read only the keys PolicyWitness needs to wire launchd and verify identity.
5+
6+
use std::path::Path;
7+
8+
use crate::plist::plist_key_string;
9+
10+
pub struct BundleInfo {
11+
pub bundle_id: String,
12+
pub executable: String,
13+
pub package_type: Option<String>,
14+
}
15+
16+
pub fn read_bundle_info(bundle_path: &Path) -> Result<BundleInfo, String> {
17+
let plist = bundle_path.join("Contents").join("Info.plist");
18+
if !plist.exists() {
19+
return Err(format!("missing Info.plist at {}", plist.display()));
20+
}
21+
let bundle_id = plist_key_string(&plist, "CFBundleIdentifier")?;
22+
let executable = plist_key_string(&plist, "CFBundleExecutable")?;
23+
let package_type = plist_key_string(&plist, "CFBundlePackageType").ok();
24+
Ok(BundleInfo {
25+
bundle_id,
26+
executable,
27+
package_type,
28+
})
29+
}

controller/src/cli.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//! Command-line entrypoint for the PolicyWitness controller.
2+
//!
3+
//! This module owns the public CLI surface and keeps the dispatch logic
4+
//! together so the rest of the controller can focus on orchestration.
5+
6+
use serde_json::json;
7+
use std::ffi::OsString;
8+
9+
use crate::json_contract;
10+
use crate::run_flow;
11+
use crate::runner_commands;
12+
13+
pub fn print_usage() {
14+
eprintln!(
15+
"\
16+
usage:
17+
policy-witness run <request.json> [--timeout-ms <n>] [--log-last <dur>] [--runner-mode <debuggable|byoxpc|machme>] [--instrumentation <json|@path>] [--sonoma-cross-check]
18+
policy-witness runner <command> [options]
19+
20+
notes:
21+
- runs the selected PWRunner XPC service once and prints a single JSON result to stdout
22+
- request.json is passed through to the runner client (or copied with runner mode/instrumentation injected)"
23+
);
24+
}
25+
26+
pub fn run(argv: Vec<OsString>) -> i32 {
27+
if argv.is_empty() {
28+
print_usage();
29+
return 2;
30+
}
31+
32+
let sub = argv[0].to_string_lossy().to_string();
33+
let rest = &argv[1..];
34+
35+
if sub == "-h" || sub == "--help" || sub == "help" {
36+
print_usage();
37+
return 0;
38+
}
39+
40+
match sub.as_str() {
41+
"run" => match run_flow::cmd_run(rest) {
42+
Ok(code) => code,
43+
Err(err) => {
44+
let result = json_contract::JsonResult {
45+
ok: false,
46+
rc: None,
47+
exit_code: Some(2),
48+
normalized_outcome: Some("tool_error".to_string()),
49+
errno: None,
50+
error: Some(err),
51+
stderr: None,
52+
stdout: None,
53+
};
54+
let data =
55+
json!({"error": "policy-witness run failed before producing a runner result"});
56+
let _ = json_contract::print_envelope("run", result, &data);
57+
2
58+
}
59+
},
60+
"runner" => match runner_commands::cmd_runner(rest) {
61+
Ok(code) => code,
62+
Err(err) => {
63+
eprintln!("{err}");
64+
2
65+
}
66+
},
67+
_ => {
68+
print_usage();
69+
2
70+
}
71+
}
72+
}

controller/src/evidence.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
//! Evidence manifest helpers for shipped binaries.
2+
//!
3+
//! The embedded manifest records hashes and entitlements so runs can report
4+
//! provenance without relying on external tooling.
5+
16
use serde::{Deserialize, Serialize};
27
use serde_json::Value;
38
use sha2::{Digest, Sha256};
@@ -68,6 +73,7 @@ pub fn sha256_hex(path: &Path) -> Result<String, String> {
6873
let mut file = File::open(path)
6974
.map_err(|e| format!("failed to open {}: {e}", path.display()))?;
7075
let mut hasher = Sha256::new();
76+
// Stream the file to avoid loading large binaries in memory.
7177
let mut buf = [0u8; 8192];
7278
loop {
7379
let n = file
@@ -90,6 +96,7 @@ pub fn verify_manifest(manifest: &EvidenceManifest, app_root: &Path, manifest_pa
9096
let mut mismatches = Vec::new();
9197
let mut checked = 0usize;
9298

99+
// Walk each entry with a declared hash and compare it to disk.
93100
for entry in &manifest.entries {
94101
let expected = entry.sha256.clone();
95102
if expected.is_none() {

controller/src/json_contract.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
//! JSON envelope helpers for controller output.
2+
//!
3+
//! The controller emits a single JSON object per run. Keys are sorted so
4+
//! envelopes are stable for diffing and hashing.
5+
16
use serde::Serialize;
27
use serde_json::{Map, Value};
38
use std::time::{SystemTime, UNIX_EPOCH};
@@ -31,6 +36,7 @@ fn sort_value(value: &mut Value) {
3136
}
3237
}
3338
Value::Object(map) => {
39+
// Sort object keys to keep output deterministic across runs.
3440
let mut entries: Vec<(String, Value)> =
3541
map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
3642
entries.sort_by(|a, b| a.0.cmp(&b.0));

0 commit comments

Comments
 (0)