PolicyWitness runs sandbox specimens and prints a single JSON result to stdout. This guide covers usage only.
PolicyWitness supports four runner modes:
- Standard runner (built-in)
- Default path; minimal entitlements (no debug allowances).
- Select with
runner.mode="standard"or--runner-mode standard.
- Debuggable runner (built-in)
- Debug-friendly entitlements plus instrumentation ports.
- Select with
runner.mode="debuggable"or--runner-mode debuggable. - Tested by
tests/suites/runner_debuggable/run.sh.
- BYOXPC runner (external XPC bundle)
- Use when you need extra entitlements; supply a signed
.xpcbundle. - Service name must match the bundle’s
CFBundleIdentifier(no override). - Install with
policy-witness runner install --kind byoxpc .... - Tested by
tests/suites/runner_byoxpc/run.sh(opt-in; GUI session required).
- MachMe runner (external Mach service binary)
- Use when you want to launch a raw binary as a Mach service.
- Install with
policy-witness runner install --kind machme --bundle /path/to/PWRunner. - Tested by
tests/suites/runner_machme/run.sh(opt-in; GUI session required).
If no runner is specified, PolicyWitness uses the built-in standard runner.
Set a convenience variable:
PW="$PWD/dist/PolicyWitness.app/Contents/MacOS/policy-witness"Create a specimen:
cat > /tmp/pw_specimen_file_read_deny.json <<'JSON'
{
"schema_version": 1,
"specimen_id": "file_read_deny",
"policy": {
"format": "sbpl",
"sbpl_source": "(version 1) (allow default) (deny file-read-data)"
},
"probe_plan": [
{
"step_id": "fr1",
"sandbox_check": {
"operation": "file-read-data",
"filter": { "kind": "path", "value": "/etc/hosts" }
},
"attempt": { "kind": "file", "action": "open_read", "target": "/etc/hosts" }
}
]
}
JSONRun it:
$PW run /tmp/pw_specimen_file_read_deny.json > /tmp/pw_result.jsonTop-level fields:
schema_version: numberspecimen_id: stringrun_kind: string (optional)policy: objectprobe_plan: array of stepsrunner: object (optional; select runner mode and external runners)instrumentation: object (optional, instrumentation port)
Minimal skeleton (copy/paste):
{
"schema_version": 1,
"specimen_id": "skeleton",
"runner": { "mode": "standard" },
"policy": { "format": "sbpl", "sbpl_source": "(version 1) (allow default)" },
"probe_plan": []
}Notes:
- All path rules live inside
policy.sbpl_source; there is nopath_membershipfield. instrumentation(if any) sits next topolicy, not inside it.probe_plancan be empty when you only want instrumentation.
SBPL source:
"policy": {
"format": "sbpl",
"sbpl_source": "(version 1) (allow default) (deny file-read-data)",
"params": { "DENY_DIR": "/tmp/deny" }
}Each step has:
step_idsandbox_check:{ operation, filter }attempt:{ kind, action, target }
Example:
{
"step_id": "read_etc_hosts",
"sandbox_check": {
"operation": "file-read-data",
"filter": { "kind": "path", "value": "/etc/hosts" }
},
"attempt": { "kind": "file", "action": "open_read", "target": "/etc/hosts" }
}The runner echoes step results with additional context:
steps[].sandbox_check:{ rc, outcome, pid, operation, scope, filter_kind, filter_value, effective_filter_value, filter_type_id, errno, error }steps[].attempt:{ rc, exit_code, errno, syscall_errno, outcome, error, requested_path, normalized_path, observed_path }
Notes:
scopeispost_sandboxfor runner-hosted checks.requested_path,normalized_path, andobserved_pathare present for file attempts; non-file attempts carry explicitnullfor these fields.filter_valueis the exact string the runner passes tosandbox_check.effective_filter_valueis a canonicalized/realpath form used for reporting only.filter_type_id:1(path),16(mach-lookup global),17(mach-lookup local).
Capture the sandbox_check argument quickly (no interpose needed):
jq '.data.runner_result.steps[].sandbox_check | {filter_value, effective_filter_value, filter_type_id, outcome}' run.jsonInstrumentation ports provide a user-friendly way to exercise the runner’s
entitlement-backed capabilities. This field is optional; if omitted, behavior
is unchanged. Results are reported under instrumentation in the run JSON and
do not change the run outcome.
These ports are part of the debuggable runner mode; use runner.mode="debuggable"
(or --runner-mode debuggable) or an external runner signed with matching entitlements.
Each port can specify an optional phase:
pre_sandbox(default)post_sandbox
Debug flow (quick recipe):
- Add a short
debug_waitto give LLDB a window before sandbox apply. - Add
dylib_loadwith your logging shim to capture sandbox_check strings. - Keep
runner.mode="debuggable"(or use an external runner with matching entitlements) so these ports are enabled. - Run:
policy-witness run <request.json> --instrumentation @instrumentation.jsonand attach to the runner PID shown in logs.
Example specimen fragment:
"instrumentation": {
"version": 1,
"ports": [
{ "kind": "debug_wait", "sleep_ms": 5000 },
{ "kind": "dylib_load", "path": "/path/to/lib.dylib", "symbol": "pw_instrumentation_init" },
{ "kind": "execmem_probe", "size_bytes": 4096 },
{ "kind": "dyld_env", "keys": ["DYLD_INSERT_LIBRARIES"] }
]
}Notes:
dylib_loadexpects an optional no-arg symbol (Cvoid func(void)).
Ports (v1):
dylib_load: load a dylib and optionally call a symbol (usescom.apple.security.cs.disable-library-validation).debug_wait: sleep before sandbox apply for debugger attach (usescom.apple.security.get-task-allow).execmem_probe: attempt a JIT mapping (MAP_JIT,PROT_READ|PROT_WRITE) and report success/failure (requirescom.apple.security.cs.allow-jit; falls back to legacy RWX if available).dyld_env: report expectedDYLD_*env vars (observation only; for actualDYLD_*injection use an external runner installed with--env DYLD_*).
Convenience flag (injects instrumentation into the request JSON at runtime):
$PW run /path/to/request.json --instrumentation @/path/to/instrumentation.jsonExample specimen (full, minimal):
{
"schema_version": 1,
"specimen_id": "instrumentation_debug_wait",
"runner": {
"mode": "debuggable"
},
"policy": {
"format": "sbpl",
"sbpl_source": "(version 1) (allow default)"
},
"instrumentation": {
"version": 1,
"ports": [
{ "kind": "debug_wait", "sleep_ms": 1 }
]
},
"probe_plan": []
}Explanation: this pauses briefly before sandbox apply; the run JSON includes an
instrumentation report with the port status and sleep_ms, and the overall
run outcome remains ok.
Guide (quick start):
- Pick a port and add it to your specimen or create a small
instrumentation.json. - Run with
policy-witness run <request.json> --instrumentation @instrumentation.json. - Inspect
data.runner_result.instrumentationin the output JSON for per-port status.
Note: dyld_env is a check only. To actually set DYLD_* variables, use an
external runner and set launchd EnvironmentVariables at install time:
$PW runner install --kind byoxpc --bundle /path/to/PWRunnerDebug.xpc --env DYLD_INSERT_LIBRARIES=/path/to/lib.dylibUse these when you need entitlements that are not in the built-in runner.
- BYOXPC: a runner
.xpcbundle to sign (typically a copy ofPWRunner.xpcorPWRunnerDebug.xpc). - MachMe: a runner binary to sign (typically
PWRunner.xpc/Contents/MacOS/PWRunneror the debug variant). - A signing identity (Developer ID Application) or ad-hoc signing for local use.
- An entitlements plist.
- A logged-in GUI session (launchd bootstrap is not available from non-GUI shells).
These sequences match the test suites and are the recommended starting point.
BYOXPC (user scope):
PW="$PWD/dist/PolicyWitness.app/Contents/MacOS/policy-witness"
IDENTITY="Developer ID Application: Your Name (TEAMID)"
ENT="$PWD/runner/services/PWRunnerDebug/Entitlements.plist"
BYO="$PWD/runtime/byosig/instances/PWRunner.byoxpc.xpc"
mkdir -p "$(dirname "$BYO")"
rm -rf "$BYO"
cp -R dist/PolicyWitness.app/Contents/XPCServices/PWRunnerDebug.xpc "$BYO"
$PW runner install --kind byoxpc \
--bundle "$BYO" \
--identity "$IDENTITY" \
--entitlements "$ENT" \
--allow-adhoc \
--scope user
$PW runner verify --service-name com.yourteam.policy-witness.PWRunnerDebug --timeout-ms 2000MachMe (user scope):
PW="$PWD/dist/PolicyWitness.app/Contents/MacOS/policy-witness"
IDENTITY="Developer ID Application: Your Name (TEAMID)"
ENT="$PWD/runner/services/PWRunnerDebug/Entitlements.plist"
BIN="$PWD/dist/PolicyWitness.app/Contents/XPCServices/PWRunnerDebug.xpc/Contents/MacOS/PWRunnerDebug"
$PW runner install --kind machme \
--bundle "$BIN" \
--identity "$IDENTITY" \
--entitlements "$ENT" \
--allow-adhoc \
--service-name com.policywitness.runner.machme \
--scope user
$PW runner verify --service-name com.policywitness.runner.machme --timeout-ms 2000$PW runner install \
--kind byoxpc \
--bundle /path/to/PWRunnerDebug.xpc \
--identity "Developer ID Application: Your Name (TEAMID)" \
--entitlements /path/to/entitlements.plist \
--scope userNotes:
- Use
--allow-adhocfor local ad-hoc signing. - Use
--scope systemif you want a system-wide service (requires admin). - Use
--skip-bootstrapif you will runlaunchctlmanually. - Use
--env KEY=VALUEto set launchdEnvironmentVariables(forDYLD_*). - BYOXPC service name must match the bundle’s
CFBundleIdentifier(no override). - The bundle must be a valid XPC service (
CFBundlePackageType=XPC!).
The install command writes a launchd plist, bootstraps the service, and records the runner in the local registry.
$PW runner install \
--kind machme \
--bundle /path/to/PWRunner \
--identity "Developer ID Application: Your Name (TEAMID)" \
--entitlements /path/to/entitlements.plist \
--scope userNotes:
- Use
--service-nameto override the Mach service name (optional). - Use
--bundle-idto set an optional provenance identifier. - If
--bundleis a directory, pass--executableto point at the MachMe binary.
$PW runner verify --service-name <service-name>Preferred: include a runner object:
"runner": {
"id": "runner-<id-from-install>",
"mode": "byoxpc",
"required_entitlements": [
"com.apple.security.cs.disable-library-validation"
]
}Alternative: select by service name:
"runner": {
"service": "com.policywitness.runner.<id>",
"mode": "machme"
}runner.mode is optional; when present it must match the registry kind for external runners.
Valid modes: standard, debuggable, byoxpc, machme.
required_entitlements enforces a superset check before dispatch.
Quick smoke request (save as /tmp/pw_byoxpc_smoke.json):
{
"policy": { "sbpl": "(version 1) (deny default)" },
"probe_plan": [],
"runner": {
"service": "com.yourteam.policy-witness.PWRunner",
"mode": "byoxpc"
}
}Replace service and mode for MachMe runners.
Then run:
$PW run /tmp/pw_byoxpc_smoke.json --timeout-ms 20000$PW runner list
$PW runner remove --id runner-<id>External runners install a launchd background item. runner remove is the
preferred uninstall path and removes the launchd entry and registry record.
If you no longer have the registry entry, uninstall manually:
User scope:
launchctl bootout "gui/$(id -u)/<service-name>"
rm -f "$HOME/Library/LaunchAgents/<service-name>.plist"System scope:
sudo launchctl bootout "system/<service-name>"
sudo rm -f "/Library/LaunchDaemons/<service-name>.plist"Registry location:
~/Library/Application Support/PolicyWitness/runners.json
--timeout-ms <n>: runner RPC timeout (default 240000)--log-last <dur>: unified log lookback window for deny capture (default 10s)--runner-mode <standard|debuggable|byoxpc|machme>: injectrunner.modeinto the request--instrumentation <json|@path>: inject instrumentation ports into the request--sonoma-cross-check: run an sb_api_validator cross-check against the runner PID while it is paused post-sandbox; results are attached underdata.sonoma_cross_check
- Service not found: run
policy-witness runner listand confirm the service name. - System scope install fails: use
--scope useror run with admin privileges. - Verify fails with no reply: check launchd state and the service plist.
- BYOXPC crashes at launch: confirm
XPC_SERVICE_PATHis set and the bundle is a valid XPC service (CFBundlePackageType=XPC!). - If you are running inside a sandboxed automation harness, XPC lookup can be blocked; run from a normal Terminal to confirm behavior.
- If
--sonoma-cross-checkreportsblockedorunavailable, rerun from an unsandboxed Terminal context (the helper needs to observe a live runner PID).
Common decode errors (quick fixes)
missing field 'policy': add a top-levelpolicyobject withformatandsbpl_source.keyNotFound(... "specimen_id" ...): add a top-levelspecimen_idstring.unknown field 'path_membership': path rules belong inpolicy.sbpl_sourceas SBPL, not as JSON fields.- Instrumentation ignored: ensure
instrumentationis top level andrunner.modeisdebuggable(or use--runner-mode debuggable, or an external runner with matching entitlements).