[pitboss/grind] deferred session-0023 (20260517T044708Z-e058)

This commit is contained in:
pitboss 2026-05-17 08:10:32 -05:00
parent f4793b0439
commit b638cade34
8 changed files with 458 additions and 26 deletions

223
tools/sb-trace.sh Executable file
View file

@ -0,0 +1,223 @@
#!/usr/bin/env bash
# tools/sb-trace.sh — corpus-walking seed generator for the macOS
# sandbox-exec deny-default rollout (Phase 18 follow-up path (a)).
#
# What it does
# ------------
# For each `.sb` profile shipped under `src/dynamic/sandbox_profiles/`,
# this script re-runs the profile in deny-default mode against the
# per-language harness corpus under `tests/dynamic_fixtures/`,
# captures the kernel's deny trace, and writes one
# `tools/sb-trace/{cap}.allow` seed file with the minimum allow rules
# the interpreter cold-start needs.
#
# The seed files are consumed by `src/dynamic/sandbox/process_macos.rs`
# at runtime when `NYX_SB_DENY_DEFAULT=1` is set; the splice path
# replaces the baked `(allow default)` with `(deny default)` and
# appends the seed body verbatim.
#
# Usage
# -----
# tools/sb-trace.sh # walk every profile + every lang fixture
# tools/sb-trace.sh cmdi # just the cmdi profile
# tools/sb-trace.sh cmdi python # cmdi + python only
#
# Requirements
# ------------
# * macOS host with `/usr/bin/sandbox-exec` available
# * `python3`, `node`, `ruby`, `php`, `java` resolvable via $PATH for
# every language whose fixtures you want to walk
#
# Output
# ------
# tools/sb-trace/<cap>.allow — generated seed, hand-review
# tools/sb-trace/<cap>.trace.raw — full raw deny trace, for audit
#
# The seed files are intended to be committed; the .trace.raw files
# are .gitignore'd because they capture host-specific paths.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SEED_DIR="$ROOT/tools/sb-trace"
PROFILE_DIR="$ROOT/src/dynamic/sandbox_profiles"
FIXTURE_ROOT="$ROOT/tests/dynamic_fixtures"
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "sb-trace: must run on macOS (uname=$(uname -s))" >&2
exit 2
fi
if ! command -v /usr/bin/sandbox-exec >/dev/null 2>&1; then
echo "sb-trace: /usr/bin/sandbox-exec missing" >&2
exit 2
fi
mkdir -p "$SEED_DIR"
# ── Profile + language coverage ──────────────────────────────────────────────
ALL_PROFILES=(base cmdi path_traversal ssrf deserialize xxe)
ALL_LANGS=(python javascript ruby php java)
selected_profiles=()
selected_langs=()
if [[ $# -ge 1 ]]; then
selected_profiles+=("$1")
else
selected_profiles=("${ALL_PROFILES[@]}")
fi
if [[ $# -ge 2 ]]; then
selected_langs+=("$2")
else
selected_langs=("${ALL_LANGS[@]}")
fi
# ── Per-language probe ───────────────────────────────────────────────────────
#
# Each probe runs the language's interpreter cold-start path (import
# the standard libraries the harness needs). The probes are
# intentionally minimal: they exercise filesystem reads of stdlib /
# package manager locations + a `mach-lookup` for the system
# notification center, which is what the trace needs to enumerate.
probe_command_for() {
local lang="$1"
case "$lang" in
python)
echo "/usr/bin/python3" "-c" "import socket,subprocess,os,sys,json"
;;
javascript)
command -v node >/dev/null 2>&1 || { echo ""; return; }
echo "node" "-e" "require('fs');require('os');require('child_process');require('http');"
;;
ruby)
command -v ruby >/dev/null 2>&1 || { echo ""; return; }
echo "ruby" "-e" "require 'json';require 'socket';require 'net/http';require 'open3'"
;;
php)
command -v php >/dev/null 2>&1 || { echo ""; return; }
echo "php" "-r" "echo phpversion();"
;;
java)
command -v java >/dev/null 2>&1 || { echo ""; return; }
echo "java" "--version"
;;
*)
echo ""
;;
esac
}
# ── Trace helper ─────────────────────────────────────────────────────────────
#
# Builds a deny-default variant of the named profile, runs the probe
# under it, captures the sandbox trace via the `(with trace)` directive,
# and prints any deny lines for further processing.
trace_one() {
local profile_name="$1"
local lang="$2"
local probe_cmd
probe_cmd="$(probe_command_for "$lang")"
if [[ -z "$probe_cmd" ]]; then
echo "sb-trace: skipping $lang (interpreter missing)" >&2
return 0
fi
local source="$PROFILE_DIR/$profile_name.sb"
if [[ ! -f "$source" ]]; then
echo "sb-trace: profile $profile_name missing at $source" >&2
return 1
fi
local tmp_profile
tmp_profile="$(mktemp -t "sb-trace-$profile_name.XXXXXX.sb")"
local trace_file
trace_file="$(mktemp -t "sb-trace-$profile_name.XXXXXX.trace")"
# Rewrite (allow default) -> (deny default), append a trace directive.
# `(trace "...")` emits one s-expression record per sandbox decision.
sed 's/(allow default)/(deny default)/' "$source" >"$tmp_profile"
printf '\n(trace "%s")\n' "$trace_file" >>"$tmp_profile"
# Run the probe under the new profile. Exit code is ignored — the
# interpreter is expected to fail under deny-default; what we want is
# the captured trace.
/usr/bin/sandbox-exec -f "$tmp_profile" -D WORKDIR=/tmp -- $probe_cmd >/dev/null 2>&1 || true
if [[ -s "$trace_file" ]]; then
cat "$trace_file"
fi
rm -f "$tmp_profile" "$trace_file"
}
# ── Trace summariser ─────────────────────────────────────────────────────────
#
# The sandbox-exec trace format records one s-expression per decision.
# We extract the deny records, normalise the per-host paths into
# parameterised allow rules, and dedupe.
summarise_traces() {
awk '
/\(deny / {
sub(/.*\(deny /, "")
sub(/\).*/, "")
print
}
' | sort -u
}
# ── Emit seed for one profile ────────────────────────────────────────────────
emit_seed() {
local profile_name="$1"
shift
local langs=("$@")
local raw="$SEED_DIR/$profile_name.trace.raw"
: >"$raw"
for lang in "${langs[@]}"; do
echo ";; ── trace from $lang probe ───────────────────────────" >>"$raw"
trace_one "$profile_name" "$lang" >>"$raw" || true
done
if [[ ! -s "$raw" ]]; then
echo "sb-trace: no deny traces captured for $profile_name" >&2
return 0
fi
local seed="$SEED_DIR/$profile_name.allow"
{
echo ";; tools/sb-trace/$profile_name.allow"
echo ";; Generated by tools/sb-trace.sh against per-language harness corpus."
echo ";; Hand-review before commit: paths under \$HOME need to be regex'd"
echo ";; rather than literalised so the seed survives a different host's"
echo ";; \$HOME layout."
echo ";;"
echo ";; Languages walked: ${langs[*]}"
echo ";; Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo
summarise_traces <"$raw" | sed 's/^/(allow /;s/$/)/'
} >"$seed"
echo "sb-trace: wrote $seed ($(wc -l <"$seed" | tr -d ' ') lines)"
}
# ── Main ─────────────────────────────────────────────────────────────────────
for profile in "${selected_profiles[@]}"; do
emit_seed "$profile" "${selected_langs[@]}"
done
echo "sb-trace: done."
echo "Next steps:"
echo " 1. Hand-review each tools/sb-trace/*.allow seed"
echo " 2. Replace host-specific literal paths with regex matches"
echo " (e.g. /Users/<you>/.pyenv/... -> ^/Users/[^/]+/\\.pyenv/)"
echo " 3. Commit the .allow files; the .trace.raw files are .gitignore'd"
echo " 4. Run nyx with NYX_SB_DENY_DEFAULT=1 to exercise the splice"

77
tools/sb-trace/README.md Normal file
View file

@ -0,0 +1,77 @@
# sb-trace seeds
This directory holds per-capability allowlist seeds for the macOS
sandbox-exec deny-default rollout.
## What the seeds are
Each `.allow` file is a fragment of sandbox-exec profile syntax (one
or more `(allow ...)` directives, plus comments). At runtime,
`src/dynamic/sandbox/process_macos.rs::profile_path` consults the
`NYX_SB_DENY_DEFAULT` environment variable; when set, it locates the
seed for the active capability, rewrites the baked profile's
`(allow default)` directive to `(deny default)`, and appends the seed
body verbatim. Sandbox-exec resolves later directives over earlier
ones, so the appended allow rules stack on top of the deny baseline.
The splice path lives in `process_macos.rs::splice_deny_default`; it
is pure, unit-tested, and a no-op when the seed for a capability is
missing. Misconfiguration cannot brick the sandbox-exec backend.
## How the seeds get generated
Run `tools/sb-trace.sh` from a macOS host that has the interpreters
on `$PATH`. The script materialises each `.sb` profile in
deny-default form, runs the per-language harness cold-start
(`python3 -c 'import socket,subprocess,...'`, `node -e require(...)`,
etc.) under it, captures the sandbox-exec trace, and emits a
candidate seed.
Output goes to this directory:
tools/sb-trace/<cap>.allow (committed)
tools/sb-trace/<cap>.trace.raw (audit artifact, gitignored)
After a run, hand-review each `.allow` seed before committing. The
script's emitted seeds usually need two passes:
1. Replace host-specific literal paths with regex matches. For
instance `/Users/eli/.pyenv/versions/3.11/lib/python3.11/...`
should become a regex anchored on `^/Users/[^/]+/\\.pyenv/`.
2. Group related `mach-lookup` rules into one allow directive when
they share a service prefix.
## Activating a seed at runtime
Set both env vars before invoking `nyx`:
export NYX_SB_DENY_DEFAULT=1
export NYX_SB_SEED_DIR="$(pwd)/tools/sb-trace"
The seed dir defaults to `tools/sb-trace/` relative to the workspace
root, so the second env var is only needed when running outside the
workspace.
The runtime splice is opt-in. Production builds leave the baked
`(allow default)` body intact unless the operator flips the env var.
## Verifying a seed end-to-end
The smoke test `deny_default_seed_loads_under_strict` in
`tests/sandbox_hardening_macos.rs` exercises the splice through the
production call site. It writes a synthetic seed to a tempdir,
points `NYX_SB_SEED_DIR` at it, calls `profile_path`, and asserts the
materialised file contains both `(deny default)` and the synthetic
seed body.
For a real-host smoke test against a generated seed, run:
NYX_SB_DENY_DEFAULT=1 \
NYX_SB_SEED_DIR="$(pwd)/tools/sb-trace" \
cargo nextest run --features dynamic --test sandbox_hardening_macos
When every cap profile has a seed that lets the python3 / node
cold-start clear, the macOS strict-mode acceptance row in
`.github/workflows/dynamic.yml` flips from "ships (allow default)" to
"ships deny-default by default" — that's the closing condition for
the Phase 18 follow-up.