diff --git a/README.md b/README.md index 324aa1f..7c8d9fb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # omp-sandbox -A macOS [Seatbelt](https://www.chromium.org/developers/design-documents/sandbox/osx-sandboxing-design/) wrapper for [Oh My Pi (omp)](https://omp.dev) that confines the AI agent to a specific workspace directory with strict read and write boundaries — without patching omp or adding any runtime dependencies. +A macOS [Seatbelt](https://www.chromium.org/developers/design-documents/sandbox/osx-sandboxing-design/) wrapper for [Oh My Pi (omp)](https://omp.dev) that applies Seatbelt filesystem and process-metadata restrictions to the AI agent — confining file writes to a workspace and explicit exceptions, denying most `$HOME` reads, and denying host process enumeration — without patching omp or adding any runtime dependencies. + +> **Isolation model — read this first.** +> This is **host macOS Seatbelt**, not a VM or container. OMP and all child commands run at the **same UID and in the same OS session** as the invoking shell. Seatbelt restricts file I/O and process metadata (see below), but does **not** provide process separation, network egress filtering, or credential isolation from same-UID children. **For strong isolation, use a separate VM or OS user account.** +> +> - **OMP-injected environment variables** (`PI_SESSION_FILE`, `PI_TOOL_BRIDGE_URL`, `PI_TOOL_BRIDGE_TOKEN`, `PI_TOOL_BRIDGE_SESSION`) are injected by OMP itself into OMP-managed command/tool children's environment. This wrapper cannot suppress them without breaking the tool bridge. `PI_SESSION_FILE` was observed mode `0644`; OMP should create it `0600` for cross-user defense, but that would not isolate same-UID sandbox children either way. +> - **Process metadata is now denied** except for the process itself (see table and security model below). Same-UID signals and IPC remain unrestricted. +> - **Executable denylists are not a security boundary** in Seatbelt. +> - **Network egress is fully open** — all hosts are reachable. An external proxy or firewall is required for network control. ## What it does @@ -10,6 +18,7 @@ A macOS [Seatbelt](https://www.chromium.org/developers/design-documents/sandbox/ | **Read ($HOME)** | Allowlist: `~/.omp` and your workspace only — everything else in `$HOME` is denied | | **Read (system)** | Open via `allow default` — OMP's runtimes need `/usr`, `/System`, `/Library`, `/opt/homebrew`, etc. | | **Network** | Open — Seatbelt cannot do per-domain filtering; OMP needs model APIs | +| **Process metadata** | Denied for host processes; allowed for self only (`process-info* (target self)`) | | **YOLO** | Opt-in (`OMP_SANDBOX_YOLO=1`) — auto-approves all tool calls | Personal data that is read-denied by default: Keychain, Messages, Mail, Calendars, Contacts, iCloud Drive, Safari, Health, Wallet, Passes, and all other `$HOME` paths not in the allowlist. @@ -72,6 +81,10 @@ OMP_SANDBOX_WORKSPACE=~/projects/myproject OMP_SANDBOX_YOLO=1 ~/.omp/sandbox/omp # Allow omp to write screenshots to ~/Downloads in addition to the workspace OMP_SANDBOX_EXTRA_WRITE=~/Downloads ~/.omp/sandbox/omp-sandboxed +# Allow omp's mnemon memory store: recall + remember (read+write) +# ~/.mnemon must be passed to BOTH EXTRA_WRITE (for remember) and EXTRA_READ. +OMP_SANDBOX_EXTRA_WRITE=~/.mnemon OMP_SANDBOX_EXTRA_READ=~/.mnemon ~/.omp/sandbox/omp-sandboxed + # Pass provider API key into the minimal clean environment (required by default) OMP_SANDBOX_PASS_ENV=ANTHROPIC_API_KEY OMP_SANDBOX_WORKSPACE=~/projects/myproject \ ~/.omp/sandbox/omp-sandboxed @@ -97,6 +110,7 @@ Only these locations are writable inside the sandbox: | `/private/var/folders` | Bun/Python bytecode caches | | `/dev` | Device nodes (`/dev/null`, `/dev/urandom`, pipes) | | `$OMP_SANDBOX_EXTRA_WRITE` | Opt-in extra paths | +| `~/.mnemon` (via `EXTRA_WRITE`+`EXTRA_READ`) | OMP's persistent memory store (mnemon). **Opt-in**: set `OMP_SANDBOX_EXTRA_WRITE=~/.mnemon` and `OMP_SANDBOX_EXTRA_READ=~/.mnemon` to enable recall+remember under the sandbox. | ### Read allowlist (within `$HOME`) @@ -105,6 +119,7 @@ Only these `$HOME` paths are readable: - `~/.omp` — OMP configuration, memories, session state - `$OMP_SANDBOX_WORKSPACE` — your project - `$OMP_SANDBOX_EXTRA_READ` — opt-in extra `$HOME`-subtree paths; canonicalized and sensitive-path-checked the same way as `EXTRA_WRITE` (so `EXTRA_READ=~/.ssh` requires `OMP_SANDBOX_CONFIRM_SENSITIVE_READ=1`). Use it when a tool needs to read a `$HOME` path that is neither `~/.omp` nor the workspace (e.g. a notes dir, a model cache). +- `~/.mnemon` (via `EXTRA_READ`+`EXTRA_WRITE`) — OMP's persistent memory store (mnemon). **Opt-in**: pass `~/.mnemon` in both `OMP_SANDBOX_EXTRA_READ` and `OMP_SANDBOX_EXTRA_WRITE` to enable `mnemon recall`/`mnemon remember` inside the sandbox. No `MNEMON_DATA_DIR` env forward is needed: with the store at its default `~/.mnemon` (a real directory, not a symlink), mnemon resolves the path itself; its only `$HOME` ancestor is `$HOME`, already covered by the home-node metadata rule. All other `$HOME` paths are denied: `~/.ssh`, `~/.aws`, `~/.zsh_history`, `~/.docker`, `~/.kube`, `~/Library/Messages`, `~/Library/Mail`, `~/Library/Keychains`, `~/Library/Mobile Documents` (iCloud), `~/Library/Health`, `~/Library/Passes`, Safari, Contacts, and everything else. @@ -131,33 +146,62 @@ Notes: By default the launcher uses `env -i` to give the sandboxed process only a minimal allowlist (PATH, HOME, TMPDIR, locale, terminal variables). All other shell variables — AWS credentials, GitHub tokens, and any other secrets in your shell — are **stripped automatically**. +**OTel trace export is intentionally disabled in clean mode.** The wrapper hardcodes `OTEL_SDK_DISABLED=true` and `OTEL_TRACES_EXPORTER=none` in the clean env. Without these the user's own OTel kill-switches (set in `.zshrc`/`.bashrc`) are stripped by `env -i`, which exposed a Bun `node:http2` crash on first launch in a telemetry path launched or bundled by OMP (`TypeError: The "authority" argument must be of type string — received number 825110816`, where `825110816` = `0x312e3120` = ASCII `"1.1 "` — a protocol-line byte sequence). The exact downstream component is unidentified [INFERENCE]; public reproductions matching the number and stack are Claude Code OTel gRPC issues. OTEL endpoint and header variables are not forwarded; only the two standard disable flags are hardcoded. + OMP needs its model provider key to operate. Pass it explicitly: ```bash OMP_SANDBOX_PASS_ENV=ANTHROPIC_API_KEY ~/.omp/sandbox/omp-sandboxed ``` -To revert to the old full-env behaviour (not recommended): - -```bash -OMP_SANDBOX_INHERIT_ENV=1 ~/.omp/sandbox/omp-sandboxed -``` +`OMP_SANDBOX_INHERIT_ENV=1` passes the full parent environment instead of the minimal allowlist — **insecure**: all shell secrets are exposed to the sandboxed process and every host it can reach. Use only for debugging. ### HOME verification The launcher resolves the real user home via `/usr/bin/dscl` and **rejects launch** if the inherited `$HOME` differs — this is the exact vector for read-confinement bypass (a malicious wrapper could set `HOME=/tmp` before launch). Set `OMP_SANDBOX_SKIP_HOME_VERIFY=1` to override. +### Process metadata isolation + +The profile immediately after `(allow default)` adds: + +```scheme +(deny process-info*) +(allow process-info* (target self)) +``` + +`process-info*` covers all process metadata operations: listing host PIDs (`proc_listpids`, `KERN_PROC_ALL`), reading argv/environment of another process (`proc_pidinfo`), and path lookup (`proc_pidpath`). Without this rule any sandboxed child could enumerate all running processes and read their metadata. + +`(allow process-info* (target self))` re-allows the process to query its own metadata, which is required by normal startup (dyld, crash reporters, signal handling). + +**Residual:** Same-UID `kill` and SHM/IPC are **not** covered by `process-info*`. A sandboxed child at the same UID can still signal the parent shell. There is no expressible Seatbelt rule to restrict signal direction between same-UID processes without also blocking OMP's own child management. Separate VM or OS user is required for full process separation. + +Proven on this configuration: `/bin/ps -axo pid=` exits rc71 (denied); `proc_listpids` count = 0; `proc_pidpath` returns 0 for arbitrary PIDs while succeeding for self. Self-test 5b asserts this behavior on every run. + ### Known remaining surface - **`/Volumes`** — mounted external/network drives are readable. Add `(deny file-read* (subpath "/Volumes"))` inside `gen_profile` if needed. - **Other `/Users/*` accounts** — readable on a multi-user Mac. Acceptable for a single-user personal machine. - **Network** — all hosts reachable. Use an external proxy/filter for egress control. - **`~/.omp`** — writable by the sandboxed process. A compromised agent could modify OMP's own memories and sessions. +- **Same-UID PIDs/signals** — process-info* prevents metadata enumeration, but same-UID `kill` and SHM/IPC remain unrestricted. A separate OS user or VM is required for full process separation. +- **OMP-injected bridge/session env vars** — `PI_SESSION_FILE`, `PI_TOOL_BRIDGE_URL`, `PI_TOOL_BRIDGE_TOKEN`, `PI_TOOL_BRIDGE_SESSION` are injected by OMP into OMP-managed command/tool children's environment. This wrapper cannot suppress them without breaking the tool bridge. `PI_SESSION_FILE` was observed mode `0644`; OMP should create it `0600` for cross-user defense, but that would not isolate same-UID sandbox children (they share the Seatbelt profile that allows `~/.omp`). +- **Local service endpoints** — the tool bridge runs on a private loopback port reachable by any sandboxed child. Seatbelt is not a per-host/domain egress firewall; blocking local loopback would break the bridge. + +### What this does NOT protect against — non-VM threat model -### What this does NOT protect against +This wrapper applies a macOS Seatbelt (TrustedBSD MAC) policy to the OMP process tree. It is **not** a VM, container, or separate kernel namespace. That distinction produces unavoidable residual risks: + +| Risk | Reason | Mitigation outside this wrapper | +|---|---|---| +| **OMP-injected bridge/session credentials** | `PI_TOOL_BRIDGE_TOKEN`, `PI_TOOL_BRIDGE_URL`, `PI_TOOL_BRIDGE_SESSION`, `PI_SESSION_FILE` are injected by OMP into tool-child processes at runtime, inside the sandbox. They do not originate in the launching shell; `env -i` never sees them. No supported OMP config knob suppresses this injection. | Separate UID or VM/container boundary | +| **Same-UID child file access** | OMP and all its child commands run as the same OS user. The Seatbelt profile allows `~/.omp`; any sandboxed child process therefore has the same read/write access to session files, history, and logs as OMP itself. `chmod 0600` on individual files protects against *other local users* only, not same-UID children. | Separate UID or VM/container boundary | +| **Tool bridge loopback reachability** | The OMP tool bridge listens on a private loopback port. Seatbelt has no per-host/port egress filtering; any sandboxed child can connect to it. Blocking loopback would break the bridge. | VM/container with private network namespace | +| **Network exfiltration** | OMP and all child processes can reach any network host. Seatbelt cannot do per-domain filtering. | External proxy/firewall for egress control | +| **Same-UID signals and IPC** | `process-info*` blocks metadata enumeration, but same-UID `kill` and SHM/IPC are unrestricted. No expressible Seatbelt rule restricts signal direction between same-UID processes without also breaking OMP's own child management. | Separate UID or VM/container boundary | +| **`~/.omp` store integrity** | `~/.omp` is intentionally writable; a compromised agent can modify OMP memories, sessions, and config. | Out of scope for a read/write sandbox | +| **Executable controls** | Path-based execution controls are ineffective; any binary can be copied into the writable workspace. Seatbelt has no code-signing enforcement in this profile. | OS-level mandatory access control or VM | -- Network exfiltration — OMP (and all child processes) can reach any host -- Attacks through OMP's memory/session store — `~/.omp` is intentionally writable +**Recommendation for high-risk workloads:** run OMP inside a VM or container with a private network namespace, a dedicated low-privilege UID, and an egress-filtering firewall. This wrapper materially narrows the *filesystem* attack surface on a shared machine and blocks host process enumeration, but it cannot substitute for OS-level UID or namespace isolation. ## Verifying the sandbox @@ -173,6 +217,7 @@ All lines should read `PASS:` and the script exits 0. The self-test checks: - `~/.omp` is both readable and writable - Workspace reads work (proves the `$HOME` deny + re-allow rule resolves correctly) - Keychain, Messages, and iCloud reads are denied +- Host process enumeration via `/bin/ps -axo` is **denied** by the `process-info*` rule; ordinary child execution (`/bin/bash -c 'echo ok'`) succeeds (`process-info* (target self)` allows self-inspection) - `omp --version` boots cleanly under the minimal clean environment (same as real launches) - `--auto-approve` flag is accepted by the installed omp version - `OMP_SANDBOX` and `PI_SANDBOX` env markers are present inside the clean env @@ -183,6 +228,8 @@ All lines should read `PASS:` and the script exits 0. The self-test checks: - `readdir` of the `$HOME` node itself stays DENIED — companion to the above; proves the carve-out is metadata-only (passes under `file-read-metadata`, fails under a widened `file-read*`), so a future change that broadens the rule and starts disclosing top-level `$HOME` entry names flips this assertion - `OMP_SANDBOX_EXTRA_READ`'s sensitive-path gate fires end-to-end — `EXTRA_READ=~/.ssh` aborts profile generation with the sensitive-deny error unless `OMP_SANDBOX_CONFIRM_SENSITIVE_READ=1` is set, in which case `~/.ssh` appears in the read allowlist as a `(subpath ...)` rule. Mirrors the `.ssh`-as-`EXTRA_WRITE` test; proves the new read-side opt-in path is gated the same way as `EXTRA_WRITE` (reading secrets is as bad as writing them) - an `OMP_SANDBOX_EXTRA_READ` path is actually **readable inside the sandbox** — with `EXTRA_READ` unset, `cat` of a canary file under a `$HOME` subdir that no baseline allowlist covers is denied by the `$HOME` read-deny rule; with `EXTRA_READ=that subdir`, `cat` succeeds (the `(subpath ...)` rule opened the `$HOME`-denied path). Placing the canary inside `$HOME` is what makes this meaningful — outside `$HOME` everything is already readable via `(allow default)`, so an outside-`$HOME` canary would pass either way and prove nothing. Mirrors self-tests #3/#4 (direct `sandbox-exec -f "$PROFILE" /bin/bash -c 'cat ...'`, not through omp). +- the omp memory store is **reachable inside the sandbox** — when `mnemon` is installed and `~/.mnemon` exists, `mnemon recall` opens the database under a profile generated with `~/.mnemon` passed via `OMP_SANDBOX_EXTRA_WRITE`+`EXTRA_READ` (proving the `EXTRA_*` mechanism and the `$HOME`-node stat rule compose to cover the mnemon case the same way any user opt-in does). The self-test folds `~/.mnemon` into `EXTRA_*` automatically; in production you set them yourself. Skipped if `mnemon` is absent or the store dir does not exist. +- `OTEL_SDK_DISABLED=true` and `OTEL_TRACES_EXPORTER=none` are present in the clean env seen by sandbox children — regression guard for the Bun `node:http2` first-launch crash (authority-as-integer `825110816`) ## Printing the active profile diff --git a/omp-sandboxed b/omp-sandboxed index 38010fc..82a479e 100755 --- a/omp-sandboxed +++ b/omp-sandboxed @@ -1,7 +1,28 @@ #!/usr/bin/env bash -# omp-sandboxed — run Oh My Pi (omp) inside a macOS Seatbelt sandbox. +# omp-sandboxed — run Oh My Pi (omp) inside a macOS Seatbelt (sandbox-exec) sandbox. # https://github.com/bnivanov/omp-sandbox # +# ISOLATION MODEL — READ THIS FIRST +# This is host macOS Seatbelt, not a VM or container. OMP and all child commands +# run at the same UID and in the same OS session as the invoking shell. Seatbelt +# restricts file I/O and process metadata, but does NOT provide: +# - Process separation (same UID; same-UID signals and IPC are unrestricted) +# - Network egress filtering (Seatbelt has no per-host/domain egress firewall; +# all network destinations remain reachable — use an external proxy/firewall) +# - Credential isolation: OMP injects PI_SESSION_FILE, PI_TOOL_BRIDGE_URL, +# PI_TOOL_BRIDGE_TOKEN, PI_TOOL_BRIDGE_SESSION into OMP-managed command/tool +# children's environment; those children can read these. PI_SESSION_FILE was +# observed mode 0644 (OMP should create it 0600 for cross-user defense, but +# that would not isolate same-UID sandbox children either way). This wrapper +# cannot suppress OMP-injected vars without breaking the tool bridge. +# - Executable denylists are not a security boundary in Seatbelt +# For strong isolation, use a separate VM or separate OS user account. +# +# PROCESS METADATA: denied except self +# The profile now denies process-info* (host process enumeration via /bin/ps, +# proc_listpids, proc_pidpath, etc.) and re-allows process-info* for the process +# itself. Residual: same-UID kill/signals and SHM/IPC remain unrestricted. +# # Write/read boundary: # WRITE: $OMP_SANDBOX_WORKSPACE (default: $PWD) # ~/.omp ← OMP sessions, memories, logs (required) @@ -9,10 +30,12 @@ # /private/var/folders ← Bun/Python caches (required) # /dev ← device nodes (required) # $OMP_SANDBOX_EXTRA_WRITE ← colon-sep opt-in extra paths +# (mnemon: pass ~/.mnemon via EXTRA_WRITE+EXTRA_READ; see README) # WRITE DENIED: everything else # # READ (home allowlist — all other $HOME is denied): # ~/.omp + $OMP_SANDBOX_WORKSPACE + $OMP_SANDBOX_EXTRA_READ +# (mnemon: pass ~/.mnemon via EXTRA_READ+EXTRA_WRITE; see README) # (EXTRA_READ mirrors EXTRA_WRITE: same canonicalization + sensitive-path # denylist; sensitive reads must be approved via OMP_SANDBOX_CONFIRM_SENSITIVE_READ=1) # READ (system): /usr /System /Library /opt/homebrew /private/var /private/tmp /dev @@ -184,10 +207,18 @@ _sbx_canon_extra_path() { # _sbx_build_clean_env — populate _clean_env with the minimal env -i allowlist (Finding-3). # Called by both --self-test (adversarial canary) and the final exec so both paths # exercise identical policy. Pass provider keys with OMP_SANDBOX_PASS_ENV=VAR1:VAR2. +# +# Limitation: PI_TOOL_BRIDGE_TOKEN, PI_TOOL_BRIDGE_SESSION, PI_TOOL_BRIDGE_URL, and +# PI_SESSION_FILE are injected by OMP into its tool children at runtime, inside the +# sandbox, after this env -i boundary. They originate inside OMP — not in the launching +# shell — so stripping them here has no effect. No supported OMP configuration knob +# suppresses this injection. Isolating these credentials requires a separate UID or a +# VM/container boundary outside this wrapper's reach. _sbx_build_clean_env() { local _sv _passval _passenv _clean_env=("HOME=$HOME_DIR" "TMPDIR=$TMPDIR_CANON" \ - "OMP_SANDBOX=seatbelt" "PI_SANDBOX=seatbelt") + "OMP_SANDBOX=seatbelt" "PI_SANDBOX=seatbelt" \ + "OTEL_SDK_DISABLED=true" "OTEL_TRACES_EXPORTER=none") # preserve user OTel kill-switches; stripping exposes Bun node:http2 crash in downstream telemetry for _sv in PATH USER LOGNAME SHELL LANG LC_ALL LC_CTYPE LC_MESSAGES \ TERM TERM_PROGRAM COLORTERM __CF_USER_TEXT_ENCODING COMMAND_MODE; do [ -n "${!_sv:-}" ] && _clean_env+=("$_sv=${!_sv}") @@ -206,51 +237,55 @@ _sbx_build_clean_env() { fi } -# Build extra write-allow rules from OMP_SANDBOX_EXTRA_WRITE (colon-separated). -# Each path: expand ~, canonicalize (resolves symlinks and .. so the denylist sees -# the real target), check sensitive denylist, then SBPL-escape (Finding-5). -EXTRA_RULES="" -if [ -n "${OMP_SANDBOX_EXTRA_WRITE:-}" ]; then - IFS=':' read -ra _extra <<< "$OMP_SANDBOX_EXTRA_WRITE" - for p in "${_extra[@]}"; do - [ -n "$p" ] || continue - case "$p" in "~"*) p="${HOME_DIR}${p#\~}";; esac - p="$(_sbx_canon_extra_path EXTRA_WRITE "$p")" - if _sbx_sensitive_path "$p"; then - if [ "${OMP_SANDBOX_CONFIRM_SENSITIVE_WRITE:-0}" != "1" ]; then - echo "omp-sandboxed: error: EXTRA_WRITE path '$p' covers a sensitive home location — set OMP_SANDBOX_CONFIRM_SENSITIVE_WRITE=1 to override" >&2 - exit 1 +# Build extra write+read allow rules from OMP_SANDBOX_EXTRA_WRITE / EXTRA_READ. +# Colon-separated; each path is ~-expanded, canonicalized (resolves symlinks and +# .. so the sensitive denylist sees the real target), sensitive-gated, and +# SBPL-escaped. Idempotent: resets EXTRA_RULES/EXTRA_READ_RULES each call so it +# can be re-run after the self-test folds ~/.mnemon into EXTRA_* (top-level runs +# once at script entry; the self-test re-runs it to pick up the folded paths). +# Reading secrets is as dangerous as writing them — EXTRA_READ applies the SAME +# sensitive-path denylist as EXTRA_WRITE, with its own CONFIRM_SENSITIVE_READ gate. +_sbx_build_extra_rules() { + EXTRA_RULES="" + if [ -n "${OMP_SANDBOX_EXTRA_WRITE:-}" ]; then + local _p + IFS=':' read -ra _sbx_exw <<< "$OMP_SANDBOX_EXTRA_WRITE" + for _p in "${_sbx_exw[@]}"; do + [ -n "$_p" ] || continue + case "$_p" in "~"*) _p="${HOME_DIR}${_p#\~}";; esac + _p="$(_sbx_canon_extra_path EXTRA_WRITE "$_p")" + if _sbx_sensitive_path "$_p"; then + if [ "${OMP_SANDBOX_CONFIRM_SENSITIVE_WRITE:-0}" != "1" ]; then + echo "omp-sandboxed: error: EXTRA_WRITE path '$_p' covers a sensitive home location — set OMP_SANDBOX_CONFIRM_SENSITIVE_WRITE=1 to override" >&2 + exit 1 + fi + echo "omp-sandboxed: warning: EXTRA_WRITE '$_p' covers a sensitive home location (OMP_SANDBOX_CONFIRM_SENSITIVE_WRITE=1)" >&2 fi - echo "omp-sandboxed: warning: EXTRA_WRITE '$p' covers a sensitive home location (OMP_SANDBOX_CONFIRM_SENSITIVE_WRITE=1)" >&2 - fi - EXTRA_RULES="${EXTRA_RULES} (subpath \"$(sb_escape "$p")\") + EXTRA_RULES="${EXTRA_RULES} (subpath \"$(sb_escape "$_p")\") " - done -fi - -# Build extra read-allow rules from OMP_SANDBOX_EXTRA_READ (colon-separated). -# Mirrors EXTRA_WRITE: expand ~, canonicalize via the same LABEL-aware helper, -# check the SAME sensitive-path denylist (reading ~/.ssh/.aws/Keychains is as -# dangerous as writing them), confirm-gate via OMP_SANDBOX_CONFIRM_SENSITIVE_READ. -# Distinct array (_extra_r) and accumulator (EXTRA_READ_RULES) so both can be set. -EXTRA_READ_RULES="" -if [ -n "${OMP_SANDBOX_EXTRA_READ:-}" ]; then - IFS=':' read -ra _extra_r <<< "$OMP_SANDBOX_EXTRA_READ" - for p in "${_extra_r[@]}"; do - [ -n "$p" ] || continue - case "$p" in "~"*) p="${HOME_DIR}${p#\~}";; esac - p="$(_sbx_canon_extra_path EXTRA_READ "$p")" - if _sbx_sensitive_path "$p"; then - if [ "${OMP_SANDBOX_CONFIRM_SENSITIVE_READ:-0}" != "1" ]; then - echo "omp-sandboxed: error: EXTRA_READ path '$p' covers a sensitive home location — set OMP_SANDBOX_CONFIRM_SENSITIVE_READ=1 to override" >&2 - exit 1 + done + fi + EXTRA_READ_RULES="" + if [ -n "${OMP_SANDBOX_EXTRA_READ:-}" ]; then + local _p + IFS=':' read -ra _sbx_exr <<< "$OMP_SANDBOX_EXTRA_READ" + for _p in "${_sbx_exr[@]}"; do + [ -n "$_p" ] || continue + case "$_p" in "~"*) _p="${HOME_DIR}${_p#\~}";; esac + _p="$(_sbx_canon_extra_path EXTRA_READ "$_p")" + if _sbx_sensitive_path "$_p"; then + if [ "${OMP_SANDBOX_CONFIRM_SENSITIVE_READ:-0}" != "1" ]; then + echo "omp-sandboxed: error: EXTRA_READ path '$_p' covers a sensitive home location — set OMP_SANDBOX_CONFIRM_SENSITIVE_READ=1 to override" >&2 + exit 1 + fi + echo "omp-sandboxed: warning: EXTRA_READ '$_p' covers a sensitive home location (OMP_SANDBOX_CONFIRM_SENSITIVE_READ=1)" >&2 fi - echo "omp-sandboxed: warning: EXTRA_READ '$p' covers a sensitive home location (OMP_SANDBOX_CONFIRM_SENSITIVE_READ=1)" >&2 - fi - EXTRA_READ_RULES="${EXTRA_READ_RULES} (subpath \"$(sb_escape "$p")\") + EXTRA_READ_RULES="${EXTRA_READ_RULES} (subpath \"$(sb_escape "$_p")\") " - done -fi + done + fi +} +_sbx_build_extra_rules gen_profile() { # Escape all user-controlled paths before interpolating into SBPL. @@ -258,10 +293,20 @@ gen_profile() { _esc_ws="$(sb_escape "$WORKSPACE")" _esc_home="$(sb_escape "$HOME_DIR")" _esc_tmp="$(sb_escape "$TMPDIR_CANON")" - cat < "$PROFILE" _sb_fail=0 + # Capture the user's original EXTRA_* so test 13 can restore $PROFILE to the + # entry state after clobbering it with the canary dir. (No mnemon fold here — + # test 16 generates its own self-contained profile below.) + _entry_xw="${OMP_SANDBOX_EXTRA_WRITE:-}" + _entry_xr="${OMP_SANDBOX_EXTRA_READ:-}" # Export probe paths as env vars so every bash -c string below can use # single quotes — no user-controlled values are interpolated into shell strings. @@ -318,11 +369,10 @@ if [ "${1:-}" = "--self-test" ]; then export _SBX_WS_PROBE="$WORKSPACE/.sbx_probe" export _SBX_OMP_PROBE="$HOME_DIR/.omp/.sbx_probe" - # Build clean env once here; tests 6, 7, and 9 all use env -i "${_clean_env[@]}" + # Build clean env once here; tests 6, 7, 9, 15, and 16 all use env -i "${_clean_env[@]}" # so they exercise the same policy as the real exec path. _sbx_build_clean_env - # 1. workspace write allowed if /usr/bin/sandbox-exec -f "$PROFILE" \ /bin/bash -c 'echo ok > "$_SBX_WS_PROBE" && rm "$_SBX_WS_PROBE"' 2>/dev/null; then echo "PASS: workspace write allowed" @@ -409,6 +459,26 @@ if [ "${1:-}" = "--self-test" ]; then fi fi + # 5b. process-info* denial: /bin/ps must be denied; self-inspect (bash) must succeed + # Proves (deny process-info*) fires for host enumeration while (allow process-info* + # (target self)) permits the sandboxed process to query its own metadata. + # /bin/ps calls proc_listpids or getproclist (KERN_PROC_ALL) — both need process-info + # on arbitrary targets, so rc must be non-zero when the sandbox is applied. + # Compatibility guard: bash -c 'echo ok' must exit 0 (normal child execution is + # unaffected by this rule — it is about process METADATA access, not exec). + if /usr/bin/sandbox-exec -f "$PROFILE" \ + /bin/ps -axo pid= >/dev/null 2>/dev/null; then + echo "FAIL: host process enumeration (/bin/ps) should be denied by process-info* rule"; _sb_fail=1 + else + echo "PASS: host process enumeration (/bin/ps -axo) denied" + fi + if /usr/bin/sandbox-exec -f "$PROFILE" \ + /bin/bash -c 'echo ok' >/dev/null 2>/dev/null; then + echo "PASS: ordinary child execution unaffected (process-info* self-allow works)" + else + echo "FAIL: ordinary child execution broken (process-info* self-allow may be missing)"; _sb_fail=1 + fi + # 6. omp boots under the clean env (matches real launch path — not inherited env) if _omp_ver="$(env -i "${_clean_env[@]}" \ /usr/bin/sandbox-exec -f "$PROFILE" "$OMP_BIN" --version 2>/dev/null)" && \ @@ -559,17 +629,16 @@ if [ "${1:-}" = "--self-test" ]; then _sb_fail=1 fi rm -rf "$_extread_root" - # Restore the original PROFILE so later tests aren't perturbed by the EXTRA_READ one. - OMP_SANDBOX_EXTRA_READ="" OMP_SANDBOX_WORKSPACE="$WORKSPACE" "$0" --print-profile > "$PROFILE" + # Restore $PROFILE to the entry state (the user's original EXTRA_*) so later + # tests aren't perturbed by the canary-dir profile above. + OMP_SANDBOX_EXTRA_WRITE="${_entry_xw:-}" OMP_SANDBOX_EXTRA_READ="${_entry_xr:-}" \ + OMP_SANDBOX_WORKSPACE="$WORKSPACE" "$0" --print-profile > "$PROFILE" fi # 14. bash-3.2 empty-array safety (${arr[@]+"${arr[@]}"} idiom) # On Apple stock /bin/bash (3.2.57), "${arr[@]}" on an empty array under set -u # raises "unbound variable" — even when the array was initialized as () (a known # bash 3.x bug fixed in 4.4). The exec path expands OMP_LAUNCH_ARGS when YOLO -# is unset (the default), so every normal launch crashes on bash 3.2. -# The fix uses "${arr[@]+"${arr[@]}"}" which is a safe no-op on empty arrays -# across all bash versions (3.2 through 5.x). This test verifies the fix by # running the script under /bin/bash (Apple's stock 3.2) with YOLO unset (empty # OMP_LAUNCH_ARGS) and --version (reaches the exec path). If the crash # persists, "unbound variable" appears in stderr. @@ -585,6 +654,45 @@ else fi rm -rf "$_SBX_B32_DIR" +# 15. OTEL kill-switches survive the env -i boundary. Without them the +# wrapper strips the user's own kill-switches, exposing a Bun node:http2 +# crash (TypeError: authority must be string, received number 825110816 +# = 0x312e3120) in an OMP-launched telemetry path. Exact component +# [INFERENCE]; public reproductions match Claude Code OTel gRPC issues. +_t15_otel=$(env -i "${_clean_env[@]}" /bin/bash -c 'echo "SDK=${OTEL_SDK_DISABLED} TRACES=${OTEL_TRACES_EXPORTER}"') +if [ "$_t15_otel" = "SDK=true TRACES=none" ]; then + echo "PASS: OTEL kill-switches present in clean env (OTEL_SDK_DISABLED=true, OTEL_TRACES_EXPORTER=none)" +else + echo "FAIL: OTEL kill-switches missing or wrong in clean env: got '$_t15_otel'" >&2 + _sb_fail=1 +fi + + # 16. mnemon store reachable under a profile that allowlists ~/.mnemon via the + # EXTRA_* mechanism (the same mechanism users use in production). SELF- + # CONTAINED: this test generates its OWN profile (with EXTRA_*=~/.mnemon) rather + # than reusing $PROFILE, so it cannot be silently fed or broken by test 13's + # clobber-then-restore of $PROFILE. Asserts recall RETURNED a result (grep + # '"id"'), not just exit 0 — `mnemon recall` can exit 0 with empty output on + # some failure modes, which would otherwise be a false pass. + if [ -d "$HOME_DIR/.mnemon" ] && command -v mnemon >/dev/null 2>&1; then + _t16_prof="$(/usr/bin/mktemp -t omp-sandbox-t16)" + OMP_SANDBOX_EXTRA_WRITE="$HOME_DIR/.mnemon" \ + OMP_SANDBOX_EXTRA_READ="$HOME_DIR/.mnemon" \ + OMP_SANDBOX_WORKSPACE="$WORKSPACE" "$0" --print-profile > "$_t16_prof" + _t16_out=$(env -i "${_clean_env[@]}" /usr/bin/sandbox-exec -f "$_t16_prof" \ + mnemon recall __omp_sandbox_selftest__ --limit 1 2>&1) || _t16_out="" + rm -f "$_t16_prof" + if printf '%s\n' "$_t16_out" | grep -q '"id"'; then + echo "PASS: mnemon store reachable under sandbox (recall returned a result)" + else + echo "FAIL: mnemon store NOT reachable under sandbox (recall returned no result)" >&2 + printf '%s\n' "$_t16_out" | head -5 >&2 + _sb_fail=1 + fi + else + echo "SKIP: mnemon store reachability (mnemon not installed or store dir absent)" + fi + exit "$_sb_fail" fi