From 5d69fff44925b2a01b06d43e1254d35b0c16c1cc Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Sat, 27 Jun 2026 11:35:35 +0800 Subject: [PATCH 1/3] Make mnemon reachable inside the sandbox via the opt-in EXTRA_* mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper had a dedicated MNEMON_RULES carve-out block that allowlisted omp's mnemon store (subpath read+write + $HOME-ancestor metadata stats) unconditionally. On a real-dir-at-~/.mnemon setup this is functionally redundant with the general OMP_SANDBOX_EXTRA_WRITE / EXTRA_READ opt-in: both produce byte-identical (subpath "$HOME/.mnemon") rules, and the per-ancestor stat() walk that justified the ancestor-literal lines only applied in the symlink-into-~/.local era, which is gone (the store is now a real dir at the default $HOME/.mnemon). Remove the carve-out. mnemon is now opt-in: a user who wants recall (and remember) inside the sandbox sets the same knob they use for any other extra writable/readable path, e.g. in ~/.zshrc: export OMP_SANDBOX_EXTRA_WRITE="$HOME/.mnemon" export OMP_SANDBOX_EXTRA_READ="$HOME/.mnemon" # or inherit from WRITE This is necessary, not stylistic: Seatbelt file-write* and file-read* are independent classes — a path in EXTRA_WRITE but not EXTRA_READ is write-allowed-yet-read-denied, which breaks SQLite WAL and read-modify-write patterns. EXTRA_READ inheriting EXTRA_WRITE (the ~/.zshrc that ships this) is the safe default. Drop the now-unused MNEMON_DATA_DIR env forward and NO_MNEMON opt-out: the real-dir-at-default store needs neither (mnemon resolves ~/.mnemon itself given $HOME, which the existing home-node metadata rule already covers). Harden the self-test so this stays true. The previous "fold ~/.mnemon into EXTRA_* then gen_profile" was a no-op in-process (EXTRA_RULES are computed once at script entry; re-exporting the env var does not recompute the shell var gen_profile interpolates), and test 15 only passed as a side effect of test 13's clobber-then-restore of $PROFILE via a fresh --print-profile subprocess — reorder the tests and it would silently break. - Refactor EXTRA_RULES / EXTRA_READ_RULES build into a _sbx_build_extra_rules() function (called at top level and again from the self-test fold). - Make test 15 self-contained: it generates its OWN profile via a fresh "$0" --print-profile with EXTRA_*=~/.mnemon, then asserts recall RETURNED a result (greps JSON for '"id"'), not just exit 0 — `mnemon recall` can exit 0 with empty output on failure modes, which would otherwise be a false pass. - Capture the user's entry EXTRA_* so test 13's restore step restores $PROFILE to the pre-canary state, no longer touching mnemon. Verified in a truly clean env (env -i, no EXTRA_*): - bash -n and /bin/bash -n (Apple stock 3.2): clean. - --self-test: 21 PASS, lone FAIL is the pre-existing unrelated --auto-approve omp-version issue. - test 15 detects its defect: forced-no-mnemon profile -> FAIL; reordered before test 13 -> still PASS (order-independent). - production --print-profile + recall with EXTRA_*=~/.mnemon returns 1 real result; no phantom ~/.local/share/mnemon recreated. --- README.md | 7 +++ omp-sandboxed | 132 ++++++++++++++++++++++++++++++++------------------ 2 files changed, 91 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 324aa1f..f90dd87 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,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 +101,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 +110,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. @@ -183,6 +189,7 @@ 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. ## Printing the active profile diff --git a/omp-sandboxed b/omp-sandboxed index 38010fc..4137246 100755 --- a/omp-sandboxed +++ b/omp-sandboxed @@ -9,10 +9,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 @@ -206,51 +208,56 @@ _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. @@ -311,6 +318,11 @@ if [ "${1:-}" = "--self-test" ]; then trap 'rm -f "$PROFILE" "$_err"' EXIT gen_profile > "$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 15 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 +330,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, and 15 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" @@ -559,17 +570,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 +595,32 @@ else fi rm -rf "$_SBX_B32_DIR" + # 15. 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 + _t15_prof="$(/usr/bin/mktemp -t omp-sandbox-t15)" + OMP_SANDBOX_EXTRA_WRITE="$HOME_DIR/.mnemon" \ + OMP_SANDBOX_EXTRA_READ="$HOME_DIR/.mnemon" \ + OMP_SANDBOX_WORKSPACE="$WORKSPACE" "$0" --print-profile > "$_t15_prof" + _t15_out=$(env -i "${_clean_env[@]}" /usr/bin/sandbox-exec -f "$_t15_prof" \ + mnemon recall __omp_sandbox_selftest__ --limit 1 2>&1) || _t15_out="" + rm -f "$_t15_prof" + if printf '%s\n' "$_t15_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' "$_t15_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 From 75410993a9daa4a25d4930c4132ae7299e38a969 Mon Sep 17 00:00:00 2001 From: Audrey Tang <20723+audreyt@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:45:50 +0800 Subject: [PATCH 2/3] Harden process isolation and telemetry defaults --- README.md | 58 +++++++++++++++++++++++++----- omp-sandboxed | 99 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 134 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f90dd87..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. @@ -137,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 @@ -179,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 @@ -190,6 +229,7 @@ All lines should read `PASS:` and the script exits 0. The self-test checks: - `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 4137246..45c5dc6 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) @@ -186,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}") @@ -258,17 +287,26 @@ _sbx_build_extra_rules() { } _sbx_build_extra_rules - gen_profile() { # Escape all user-controlled paths before interpolating into SBPL. local _esc_ws _esc_home _esc_tmp _esc_ws="$(sb_escape "$WORKSPACE")" _esc_home="$(sb_escape "$HOME_DIR")" _esc_tmp="$(sb_escape "$TMPDIR_CANON")" - cat </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)" && \ @@ -595,7 +653,20 @@ else fi rm -rf "$_SBX_B32_DIR" - # 15. mnemon store reachable under a profile that allowlists ~/.mnemon via the +# 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 @@ -603,18 +674,18 @@ rm -rf "$_SBX_B32_DIR" # '"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 - _t15_prof="$(/usr/bin/mktemp -t omp-sandbox-t15)" + _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 > "$_t15_prof" - _t15_out=$(env -i "${_clean_env[@]}" /usr/bin/sandbox-exec -f "$_t15_prof" \ - mnemon recall __omp_sandbox_selftest__ --limit 1 2>&1) || _t15_out="" - rm -f "$_t15_prof" - if printf '%s\n' "$_t15_out" | grep -q '"id"'; then + 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' "$_t15_out" | head -5 >&2 + printf '%s\n' "$_t16_out" | head -5 >&2 _sb_fail=1 fi else From e5216cf499689665b29db751024d0fc1c160ea96 Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Sat, 18 Jul 2026 12:41:04 +0800 Subject: [PATCH 3/3] fix(omp-sandboxed): Allowed Bun cache reads - Permitted sandboxed processes to read the user's .bun directory alongside workspace and OMP data. --- omp-sandboxed | 1 + 1 file changed, 1 insertion(+) diff --git a/omp-sandboxed b/omp-sandboxed index 45c5dc6..82a479e 100755 --- a/omp-sandboxed +++ b/omp-sandboxed @@ -327,6 +327,7 @@ ${EXTRA_RULES}) (deny file-read* (subpath "${_esc_home}")) (allow file-read* (subpath "${_esc_home}/.omp") + (subpath "${_esc_home}/.bun") (subpath "${_esc_ws}") ${EXTRA_READ_RULES}) ;; --- home-node metadata: Go programs (filepath.EvalSymlinks / os.MkdirAll)