Skip to content

Instantly share code, notes, and snippets.

@josephgimenez
Created March 1, 2026 20:18
Show Gist options
  • Select an option

  • Save josephgimenez/dc57f9397de5fb364d8d027cbcb2a6f0 to your computer and use it in GitHub Desktop.

Select an option

Save josephgimenez/dc57f9397de5fb364d8d027cbcb2a6f0 to your computer and use it in GitHub Desktop.

Here's a breakdown of the different attempts made in case it's helpful:

Workarounds Attempted Without Endpoint Security

Without the Endpoint Security entitlement, nono uses Seatbelt (sandbox_init()) for macOS sandboxing. Seatbelt is a static, apply-once sandbox — once set, permissions cannot be expanded. This creates fundamental challenges for interactive permission management. Here's what we've attempted:

1. Sandbox Extension Tokens (Failed)

Goal: Use sandbox_extension_issue_file() / sandbox_extension_consume() to dynamically grant file access to sandboxed processes.

Result: Extension tokens do not survive exec(). The kernel resets the grant table on exec, so tokens consumed before exec are wiped, and tokens consumed after exec return success but are not honored. Since nono must exec() the target program (Claude Code, shell commands, etc.), this approach is unusable.

Fallback: SCM_RIGHTS fd passing — the unsandboxed supervisor opens files and passes fds over a Unix socket. This works but only for programs that integrate the nono SDK and explicitly request fds. Unmodified programs (cat, git, ssh) can't use it.

2. Shell Proxy + DYLD Library Injection (Failed)

Goal: Intercept shell commands spawned by the AI agent and route them through nono-shell for per-command sandboxing with interactive retry prompts on denial.

Architecture:

  • Supervisor listens on a Unix socket
  • SHELL=nono-shell set in agent environment
  • DYLD_INSERT_LIBRARIES injects a C interposition library that hooks posix_spawn, posix_spawnp, and execve to redirect /bin/bash, /bin/zsh, etc. to nono-shell
  • nono-shell connects to the supervisor, which forks a per-command sandbox, monitors for denials, and offers interactive retry

Result: Two independent failures:

  1. DYLD stripped by hardened runtime. Claude Code's binary is signed with hardened runtime (flags=0x10000(runtime)), which causes macOS to strip DYLD_INSERT_LIBRARIES at exec time. The interposition library never loads. (Note: Seatbelt itself does NOT strip DYLD — this was verified empirically. It's specifically the hardened runtime on the target binary.)

  2. Claude Code ignores $SHELL. Claude Code reads process.env.SHELL but only uses it as the shell interpreter when the path contains "bash" or "zsh". Otherwise it falls back to searching /bin/bash, /bin/zsh in hardcoded paths. Since SHELL=nono-shell doesn't contain those strings, Claude Code bypasses nono-shell entirely and spawns /bin/bash directly.

Partial workaround in progress: Create a symlink proxy_dir/bashnono-shell so the SHELL path contains "bash". This is fragile — it depends on Claude Code's internal shell detection heuristics not changing.

3. Static Seatbelt Profile with Deny Overrides (Current Approach)

Goal: Use Seatbelt's rule ordering (last-match-wins at equal specificity) to allow specific files within broadly denied directories.

How it works: Policy groups like deny_credentials generate (deny file-read-data (subpath "~/.ssh")). Profile entries like read_file: ["~/.ssh/config"] generate (allow file-read-data (literal "~/.ssh/config")) as a platform rule placed after the deny, overriding it via last-match-wins.

Limitations:

  • All permissions must be declared upfront — no dynamic expansion
  • No interactive retry (denials are final within the running sandbox)
  • Complex rule ordering semantics that are undocumented and empirically discovered
  • Initial implementation had a bug where allow rules appeared before deny rules in the generated profile, requiring a separate apply_file_deny_exceptions() pass

4. Post-Exit Denial Detection + Retry (Partial)

Goal: Detect Seatbelt denials after a command fails and offer to retry with expanded permissions.

How it works:

  • DenialMonitor spawns log stream to capture Seatbelt denial events from macOS unified log
  • Stderr fallback parses "Operation not permitted" / "Permission denied" patterns
  • TtyGuard stops the agent process group, claims terminal foreground, shows retry prompt
  • If approved, adds capabilities and reruns the command

Limitations:

  • Only works in shell proxy mode where the supervisor controls command execution
  • Since DYLD injection and SHELL override both fail (see #2), commands run directly under the agent sandbox, not through per-command sandboxes — so the supervisor never sees them
  • log stream is asynchronous; denial events can arrive late or not at all
  • Requires a real TTY; doesn't work in piped/CI environments

Summary

Every approach hits the same wall: Seatbelt is static and Endpoint Security is the only supported API for dynamic, per-operation authorization on macOS. Without it, we're left with:

  • A static sandbox that can't be expanded after apply (Seatbelt)
  • Extension tokens that don't survive exec
  • DYLD injection that's stripped by hardened runtime
  • Shell interposition that depends on the target program's internal shell detection

The Endpoint Security framework solves all of these by intercepting operations at the kernel level, making allow/deny decisions in real-time from a separate supervisor process, with no requirements on the monitored process.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment