Skip to content

Commit cba1e1b

Browse files
committed
controller: preflight SBPL and surface compile failures
Add a host-side SBPL preflight helper that compiles policy.sbpl_source and emits a JSON envelope with compile status and error details. Invoke the preflight before launching any runner, fast-fail on compile errors, and include preflight/diagnostic context when XPC startup fails.
1 parent c9c89ac commit cba1e1b

File tree

11 files changed

+587
-45
lines changed

11 files changed

+587
-45
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Documentation should be stateless: describe current behavior without historical
6363
- Build: `make build` (or `./build.sh`)
6464
- Requires `IDENTITY` to be set to a **Developer ID Application** identity in your keychain (see `SIGNING.md`).
6565
- If you are in a sandboxed automation harness, signing/keychain access may fail; ask for approval/escalation and rerun.
66+
- If you add a helper under `Contents/MacOS`, update the `build.sh` signing list; notarization fails if any embedded tool is left ad hoc-signed.
6667
- Run: `PolicyWitness.app/Contents/MacOS/policy-witness run tests/fixtures/pw_runner/<request>.json > result.json`
6768

6869
Build knobs worth knowing (debugging/iteration):

SIGNING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Signing is “inside-out”:
3131
3. Sign the outer `.app` last.
3232

3333
Do not “fix” signing by adding `codesign --deep` to the signing steps. Explicitly sign the known nested binaries and then sign the outer app.
34+
When you add a new embedded helper under `Contents/MacOS`, add an explicit signing step in `build.sh`. Notarization will fail if any embedded tool remains ad hoc-signed.
3435

3536
## Evidence artifacts
3637

build.sh

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,12 @@ fi
136136
echo "==> Building Rust controller + tools"
137137
cargo build --manifest-path "${RUNNER_MANIFEST}" --release \
138138
--bin policy-witness \
139-
--bin sandbox-log-observer
139+
--bin sandbox-log-observer \
140+
--bin sbpl-preflight
140141

141142
RUNNER_BIN="${ROOT_DIR}/controller/target/release/policy-witness"
142143
SANDBOX_LOG_OBSERVER_BIN="${ROOT_DIR}/controller/target/release/sandbox-log-observer"
144+
SBPL_PREFLIGHT_BIN="${ROOT_DIR}/controller/target/release/sbpl-preflight"
143145
if [[ ! -x "${RUNNER_BIN}" ]]; then
144146
echo "ERROR: expected policy-witness binary at ${RUNNER_BIN}" 1>&2
145147
exit 2
@@ -148,6 +150,10 @@ if [[ ! -x "${SANDBOX_LOG_OBSERVER_BIN}" ]]; then
148150
echo "ERROR: expected sandbox-log-observer binary at ${SANDBOX_LOG_OBSERVER_BIN}" 1>&2
149151
exit 2
150152
fi
153+
if [[ ! -x "${SBPL_PREFLIGHT_BIN}" ]]; then
154+
echo "ERROR: expected sbpl-preflight binary at ${SBPL_PREFLIGHT_BIN}" 1>&2
155+
exit 2
156+
fi
151157
if [[ ! -f "${SB_API_VALIDATOR_SRC}" ]]; then
152158
echo "ERROR: missing sb_api_validator source at ${SB_API_VALIDATOR_SRC}" 1>&2
153159
exit 2
@@ -179,6 +185,9 @@ chmod +x "${APP_BUNDLE}/Contents/MacOS/policy-witness"
179185
cp "${SANDBOX_LOG_OBSERVER_BIN}" "${APP_BUNDLE}/Contents/MacOS/sandbox-log-observer"
180186
chmod +x "${APP_BUNDLE}/Contents/MacOS/sandbox-log-observer"
181187

188+
cp "${SBPL_PREFLIGHT_BIN}" "${APP_BUNDLE}/Contents/MacOS/sbpl-preflight"
189+
chmod +x "${APP_BUNDLE}/Contents/MacOS/sbpl-preflight"
190+
182191
cp "${SB_API_VALIDATOR_BIN}" "${APP_BUNDLE}/Contents/MacOS/sb_api_validator"
183192
chmod +x "${APP_BUNDLE}/Contents/MacOS/sb_api_validator"
184193

@@ -297,6 +306,7 @@ sign_macho() {
297306
echo "==> Codesigning embedded MacOS tools"
298307
sign_macho "${APP_BUNDLE}/Contents/MacOS/pw-runner-client"
299308
sign_macho "${APP_BUNDLE}/Contents/MacOS/sandbox-log-observer"
309+
sign_macho "${APP_BUNDLE}/Contents/MacOS/sbpl-preflight"
300310
sign_macho "${APP_BUNDLE}/Contents/MacOS/sb_api_validator"
301311

302312
if [[ "${BUILD_XPC}" == "1" ]] && [[ -d "${XPC_SERVICES_DIR}" ]]; then

controller/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ path = "src/main.rs"
1212
name = "sandbox-log-observer"
1313
path = "src/bin/sandbox-log-observer.rs"
1414

15+
[[bin]]
16+
name = "sbpl-preflight"
17+
path = "src/bin/sbpl-preflight.rs"
18+
1519
[[test]]
1620
name = "cli_contract"
1721
path = "integration/cli_contract.rs"

controller/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Support modules:
2727
- `controller/src/plist.rs` — PlistBuddy helpers for Info.plist lookups
2828
- `controller/src/bundle.rs` — bundle metadata reader for external runners
2929
- `controller/src/request_patch.rs` — request JSON injection helpers
30+
- `controller/src/policy_preflight.rs` — SBPL preflight runner wiring
3031
- `controller/src/utils.rs` — shared time + output helpers
3132
- `controller/src/evidence.rs` — evidence manifest parsing + verification
3233
- `controller/src/json_contract.rs` — JSON envelope rendering with sorted keys
@@ -36,6 +37,8 @@ Standalone helper tools (embedded into the `.app`):
3637

3738
- `controller/src/bin/sandbox-log-observer.rs``PolicyWitness.app/Contents/MacOS/sandbox-log-observer`
3839
- Captures unified-log sandbox deny lines by PID + process name
40+
- `controller/src/bin/sbpl-preflight.rs``PolicyWitness.app/Contents/MacOS/sbpl-preflight`
41+
- Compiles SBPL policies and reports compiler errors before the runner launches
3942
- `controller/tools/sb_api_validator/sb_api_validator``PolicyWitness.app/Contents/MacOS/sb_api_validator`
4043
- Direct `sandbox_check` cross-check helper (used by `--sonoma-cross-check`)
4144

@@ -76,6 +79,8 @@ The controller prints one JSON envelope to stdout (`kind="run"`). It contains:
7679

7780
- `data.runner_result`: the runner's JSON (if parseable)
7881
- `data.runner_client`: argv + stdout/stderr + timing for the client call
82+
- `data.policy_preflight`: SBPL compile report from `sbpl-preflight` (best-effort)
83+
- `data.runner_startup_diagnostics`: extra context when XPC startup fails
7984
- `data.sandbox_log_capture`: optional unified-log evidence (best-effort)
8085
- `data.sonoma_cross_check`: optional `sandbox_check` cross-check report (best-effort)
8186
- `data.runner_provenance`: runner identity + entitlements metadata
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
//! SBPL preflight compiler for host-side diagnostics.
2+
//!
3+
//! This tool parses a PolicyWitness request JSON, extracts the SBPL policy,
4+
//! and runs sandbox_compile_string to catch syntax and parameter errors.
5+
6+
#[path = "../json_contract.rs"]
7+
#[allow(dead_code)]
8+
mod json_contract;
9+
10+
use serde::Deserialize;
11+
use serde::Serialize;
12+
use sha2::{Digest, Sha256};
13+
use std::collections::BTreeMap;
14+
use std::ffi::{CStr, CString};
15+
use std::os::raw::{c_char, c_int, c_void};
16+
use std::path::PathBuf;
17+
18+
#[link(name = "sandbox")]
19+
unsafe extern "C" {
20+
fn sandbox_compile_string(
21+
profile: *const c_char,
22+
params: *const c_void,
23+
errorbuf: *mut *mut c_char,
24+
) -> *mut c_void;
25+
fn sandbox_free_error(errorbuf: *mut c_char);
26+
fn sandbox_free_profile(profile: *mut c_void);
27+
fn sandbox_create_params() -> *mut c_void;
28+
fn sandbox_set_param(params: *mut c_void, key: *const c_char, value: *const c_char)
29+
-> c_int;
30+
fn sandbox_free_params(params: *mut c_void);
31+
}
32+
33+
#[derive(Deserialize)]
34+
struct PreflightRequest {
35+
policy: PreflightPolicy,
36+
}
37+
38+
#[derive(Deserialize)]
39+
struct PreflightPolicy {
40+
format: String,
41+
sbpl_source: Option<String>,
42+
params: Option<BTreeMap<String, String>>,
43+
}
44+
45+
#[derive(Serialize)]
46+
struct PreflightData {
47+
policy_format: String,
48+
policy_sha256: Option<String>,
49+
params_present: bool,
50+
params_count: usize,
51+
compiled: bool,
52+
compile_error: Option<String>,
53+
}
54+
55+
fn usage() -> String {
56+
"\
57+
usage:
58+
sbpl-preflight --request <request.json>
59+
60+
notes:
61+
- reads request.json and compiles policy.sbpl_source
62+
- prints a JSON envelope with compile status and error details"
63+
.to_string()
64+
}
65+
66+
fn sha256_hex(data: &str) -> String {
67+
let digest = Sha256::digest(data.as_bytes());
68+
digest.iter().map(|b| format!("{b:02x}")).collect()
69+
}
70+
71+
fn compile_sbpl(source: &str, params: Option<&BTreeMap<String, String>>) -> Result<(), String> {
72+
let mut param_cstrings: Vec<CString> = Vec::new();
73+
let params_obj: *mut c_void = if let Some(params) = params {
74+
if params.is_empty() {
75+
std::ptr::null_mut()
76+
} else {
77+
let obj = unsafe { sandbox_create_params() };
78+
if obj.is_null() {
79+
return Err("sandbox_create_params returned NULL".to_string());
80+
}
81+
for (key, value) in params.iter() {
82+
let key_c = CString::new(key.as_str())
83+
.map_err(|_| format!("invalid param key (NUL): {key}"))?;
84+
let value_c = CString::new(value.as_str())
85+
.map_err(|_| format!("invalid param value for {key} (NUL)"))?;
86+
let rc = unsafe {
87+
sandbox_set_param(obj, key_c.as_ptr(), value_c.as_ptr())
88+
};
89+
if rc != 0 {
90+
unsafe { sandbox_free_params(obj) };
91+
return Err(format!("sandbox_set_param failed for {key}: rc={rc}"));
92+
}
93+
param_cstrings.push(key_c);
94+
param_cstrings.push(value_c);
95+
}
96+
obj
97+
}
98+
} else {
99+
std::ptr::null_mut()
100+
};
101+
102+
let mut err_buf: *mut c_char = std::ptr::null_mut();
103+
let profile = unsafe {
104+
let cstr = CString::new(source).map_err(|_| "sbpl_source contains NUL".to_string())?;
105+
sandbox_compile_string(cstr.as_ptr(), params_obj, &mut err_buf)
106+
};
107+
108+
if !err_buf.is_null() {
109+
let message = unsafe {
110+
let msg = CStr::from_ptr(err_buf).to_string_lossy().to_string();
111+
sandbox_free_error(err_buf);
112+
msg
113+
};
114+
if !profile.is_null() {
115+
unsafe { sandbox_free_profile(profile) };
116+
}
117+
if !params_obj.is_null() {
118+
unsafe { sandbox_free_params(params_obj) };
119+
}
120+
return Err(format!("sandbox_compile_string failed: {message}"));
121+
}
122+
123+
if profile.is_null() {
124+
if !params_obj.is_null() {
125+
unsafe { sandbox_free_params(params_obj) };
126+
}
127+
return Err("sandbox_compile_string failed (no profile and no error)".to_string());
128+
}
129+
130+
unsafe { sandbox_free_profile(profile) };
131+
if !params_obj.is_null() {
132+
unsafe { sandbox_free_params(params_obj) };
133+
}
134+
Ok(())
135+
}
136+
137+
fn main() {
138+
let args: Vec<String> = std::env::args().skip(1).collect();
139+
if args.is_empty() {
140+
eprintln!("{}", usage());
141+
std::process::exit(2);
142+
}
143+
144+
let mut request_path: Option<PathBuf> = None;
145+
let mut idx = 0usize;
146+
while idx < args.len() {
147+
match args[idx].as_str() {
148+
"-h" | "--help" => {
149+
println!("{}", usage());
150+
return;
151+
}
152+
"--request" => {
153+
if let Some(path) = args.get(idx + 1) {
154+
request_path = Some(PathBuf::from(path));
155+
idx += 2;
156+
} else {
157+
eprintln!("missing value for --request");
158+
eprintln!("{}", usage());
159+
std::process::exit(2);
160+
}
161+
}
162+
other => {
163+
eprintln!("unknown argument: {other}\n\n{}", usage());
164+
std::process::exit(2);
165+
}
166+
}
167+
}
168+
169+
let request_path = match request_path {
170+
Some(path) => path,
171+
None => {
172+
eprintln!("missing --request\n\n{}", usage());
173+
std::process::exit(2);
174+
}
175+
};
176+
177+
let text = match std::fs::read_to_string(&request_path) {
178+
Ok(text) => text,
179+
Err(err) => {
180+
eprintln!("failed to read request: {err}");
181+
std::process::exit(2);
182+
}
183+
};
184+
185+
let parsed: PreflightRequest = match serde_json::from_str(&text) {
186+
Ok(req) => req,
187+
Err(err) => {
188+
eprintln!("failed to parse request.json: {err}");
189+
std::process::exit(2);
190+
}
191+
};
192+
193+
let format = parsed.policy.format;
194+
let params = parsed.policy.params.as_ref();
195+
196+
if format != "sbpl" {
197+
let data = PreflightData {
198+
policy_format: format,
199+
policy_sha256: None,
200+
params_present: params.is_some(),
201+
params_count: params.map(|p| p.len()).unwrap_or(0),
202+
compiled: false,
203+
compile_error: Some("unsupported policy.format (expected sbpl)".to_string()),
204+
};
205+
let result = json_contract::JsonResult {
206+
ok: false,
207+
rc: None,
208+
exit_code: Some(1),
209+
normalized_outcome: Some("unsupported_format".to_string()),
210+
errno: None,
211+
error: Some("unsupported policy.format (expected sbpl)".to_string()),
212+
stderr: None,
213+
stdout: None,
214+
};
215+
let _ = json_contract::print_envelope("sbpl_preflight", result, &data);
216+
std::process::exit(1);
217+
}
218+
219+
let source = match parsed.policy.sbpl_source.as_ref() {
220+
Some(src) => src,
221+
None => {
222+
let data = PreflightData {
223+
policy_format: format,
224+
policy_sha256: None,
225+
params_present: params.is_some(),
226+
params_count: params.map(|p| p.len()).unwrap_or(0),
227+
compiled: false,
228+
compile_error: Some("missing policy.sbpl_source".to_string()),
229+
};
230+
let result = json_contract::JsonResult {
231+
ok: false,
232+
rc: None,
233+
exit_code: Some(1),
234+
normalized_outcome: Some("bad_policy".to_string()),
235+
errno: None,
236+
error: Some("missing policy.sbpl_source".to_string()),
237+
stderr: None,
238+
stdout: None,
239+
};
240+
let _ = json_contract::print_envelope("sbpl_preflight", result, &data);
241+
std::process::exit(1);
242+
}
243+
};
244+
245+
let policy_sha = sha256_hex(source);
246+
let compile_result = compile_sbpl(source, params);
247+
match compile_result {
248+
Ok(()) => {
249+
let data = PreflightData {
250+
policy_format: format,
251+
policy_sha256: Some(policy_sha),
252+
params_present: params.is_some(),
253+
params_count: params.map(|p| p.len()).unwrap_or(0),
254+
compiled: true,
255+
compile_error: None,
256+
};
257+
let result = json_contract::JsonResult {
258+
ok: true,
259+
rc: None,
260+
exit_code: Some(0),
261+
normalized_outcome: Some("ok".to_string()),
262+
errno: None,
263+
error: None,
264+
stderr: None,
265+
stdout: None,
266+
};
267+
let _ = json_contract::print_envelope("sbpl_preflight", result, &data);
268+
std::process::exit(0);
269+
}
270+
Err(err) => {
271+
let data = PreflightData {
272+
policy_format: format,
273+
policy_sha256: Some(policy_sha),
274+
params_present: params.is_some(),
275+
params_count: params.map(|p| p.len()).unwrap_or(0),
276+
compiled: false,
277+
compile_error: Some(err.clone()),
278+
};
279+
let result = json_contract::JsonResult {
280+
ok: false,
281+
rc: None,
282+
exit_code: Some(1),
283+
normalized_outcome: Some("compile_error".to_string()),
284+
errno: None,
285+
error: Some(err),
286+
stderr: None,
287+
stdout: None,
288+
};
289+
let _ = json_contract::print_envelope("sbpl_preflight", result, &data);
290+
std::process::exit(1);
291+
}
292+
}
293+
}

controller/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod bundle;
88
mod cli;
99
mod evidence;
1010
mod json_contract;
11+
mod policy_preflight;
1112
mod plist;
1213
mod request_patch;
1314
mod run_flow;

0 commit comments

Comments
 (0)