coily: sandbox-safe privileged execution via filesystem RPC #49
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally filed by @coilysiren on 2026-05-10T23:15:37Z - https://github.com/coilysiren/coily/issues/111
coily: sandbox-safe privileged execution via filesystem RPC
Why
Today, an agent running inside a Cowork-mode Linux sandbox cannot perform privileged operations on Kai's behalf. Concrete example from this session: the agent wanted to file a GitHub issue on
coilysiren/coilyco-ai(a private repo) and could not, because the GitHub MCP server in the sandbox does not have private-repo scope, noghCLI is installed, no SSH keys are mounted, no PAT is available in the sandbox env, and AWS SSM is not reachable (coily is darwin/arm64, AWS CLI is not installed, AWS creds are not mounted).The agent has read/write access to
~/projects/coilysirenbut zero identity. Granting identity by pasting a PAT into the sandbox is the obvious bad answer. We want a design where:gh,aws,git,linear, anything), not just GitHub.This issue proposes the design and lists the components to build.
Current state of the sandbox (for grounding)
Confirmed in this session:
gifted-determined-planck, ephemeral session id.HOME=/sessions/gifted-determined-planck, no.ssh, no.gitconfig, no.config/gh, no.aws, no.netrc.~/projects/coilysiren(RW, live bidirectional with Mac filesystem), outputs (RW scratch), uploads (RO).https_proxy=http://localhost:3128, SOCKS atlocalhost:1080, withGIT_SSH_COMMANDwrapping ssh via socat.github.comreturns 200 withremote_ip=127.0.0.1,api.github.comandexample.comreturn 403,100.100.100.100(Tailscale MagicDNS) and*.ts.netreturn 403. So the proxy resolves and terminates TLS, and the allowlist blocks tailnet access by design.SANDBOX_RUNTIME=1. AlsoCLAUDE_CODE_HOST_HTTP_PROXY_PORTandCLAUDE_CODE_HOST_SOCKS_PROXY_PORT.These properties mean the Mac is in full control of what the sandbox can reach over the network, and the sandbox has a writable shared filesystem channel to the Mac.
Design overview
The Mac is the trust point. coily wraps every privileged tool end to end. When a sandbox-side coily invocation needs privilege, it issues an RPC over the shared filesystem to a Mac-side executor. The Mac-side executor reads creds from Keychain (or other native stores), runs the real CLI, and returns the result. Tokens never cross the sandbox boundary.
This is RPC over filesystem with launchd as the trigger. No persistent daemon. No proxy. No socket plumbing.
Why filesystem RPC and not a credential-injection proxy
An earlier draft of this design proposed a Mac-side intercepting proxy that injected
Authorization: Bearer <PAT-from-Keychain>into outbound API calls. That works, but it loses to filesystem RPC for almost every coily case because:git pushover ssh,mas install,osascript,pbcopy. Proxies are useless for those, RPC handles them uniformly.gh,aws,doctl, etc. already solved auth correctly. Reusing their native paths is strictly safer than reimplementing injection.The proxy shape is still useful for the narrow case of a sandbox-side process bypassing coily and talking to an API directly with a library. Reserve it for that. coily owns the wrappers, so this case is rare.
Components
1. SANDBOX_RUNTIME detection
Sandbox-side coily detects it is running in a Cowork sandbox by checking, in order:
$SANDBOX_RUNTIME == "1". Cleanest signal.$LOGNAMEmatches^[a-z]+-[a-z]+-[a-z]+$(the dashed-adjective name shape),$HOMEstarts with/sessions/, presence of$CLAUDE_CODE_HOST_HTTP_PROXY_PORT.When detection trips, coily switches to RPC mode for any subcommand that requires privilege. Read-only operations that work without creds (eg
coily gh repo viewon a public repo) skip RPC and run locally.Mac-side coily is the same binary, built for darwin/arm64. It does not key off
SANDBOX_RUNTIMEbecause it is never set on the Mac. Same command graph, different default execution mode.2. Descriptor schema
YAML at
~/projects/coilysiren/.coily/requests/<uuid>.yaml:Canonicalization for HMAC uses JCS (RFC 8785) over the body with the
hmacfield omitted. Mac-side coily verifies, then refuses on mismatch.3. Mac-side trigger
A launchd LaunchAgent with
WatchPathson~/projects/coilysiren/.coily/requests/. Fires Mac-side coily on every change. No always-on daemon. Plist sketch:The
_executor_drainsubcommand iterates pending descriptors FIFO, holds an exclusive lock for the duration of each exec, refuses to start a second exec while the lock is held. Anything queued during a run gets processed on the next WatchPaths fire.4. Mac-side execution
For each descriptor:
~/.coily/state/seen-nonces.db. Refuse on replay.requires_confirmis true or the policy entry forces it, send a macOS notification with allow/deny actions. Block on Kai's response.execthe real CLI with creds in env (never in args), output captured.~/projects/coilysiren/.coily/responses/<uuid>.yamlwith stdout, stderr, exit code, duration.~/.coily/audit/<yyyy-mm>.jsonl. Credentials and full response bodies never land in audit.5. Sandbox-side wait and resume
Sandbox-side coily uses fsnotify on
~/projects/coilysiren/.coily/responses/and waits for<uuid>.yaml. Backstop poll loop with timeout. On timeout it writes~/projects/coilysiren/.coily/teardowns/<uuid>.yamlso a hung Mac-side executor cannot leave orphans.6. Concurrency and rate limiting
Defaults:
~/.coily/state/buckets.yaml.Refusal taxonomy with distinct codes:
hmac-fail,replay-detected,lockout-active,pause-active,rate-limit,concurrency-busy,policy-deny,command-timeout,confirm-rejected. Sandbox-side coily branches on the code. Notably, rate-limit triggers a sandbox-side wait-and-retry with backoff. Other codes fail fast.7. Emergency stop
Two states, two verbs, two recovery costs.
coily lockout(nuclear, human only)~/.coily/state/lockout.yaml(outside the cowork mount, sandbox cannot see or write it), SIGTERMs any in-flight subprocess with SIGKILL after a 5s grace, rotates the HMAC key in Keychain. Push notification confirms.hmac-fail. There is no recovery path from inside the sandbox.coily unlockon the Mac, Touch ID gated, plus a manual re-bootstrap of the HMAC key into the sandbox via a separate command. Audit captures the lockout event, every refused request after it, and the unlock event.coily pause(soft, sandbox self-imposed)~/.coily/state/pause.yamlon the Mac side (same out-of-mount location, set indirectly via a descriptor-style RPC with no exec). Subsequent requests refuse withpause-active. HMAC is left untouched.coily resumeon the Mac, no Touch ID, no re-bootstrap. Cheap intentionally so the sandbox is encouraged to pause itself when uncertain.Default-safe failure
com.apple.screenIsLocked. Stays locked until explicitly unlocked, separate from Mac session unlock.Always-confirm, orthogonal to lockout
Some subcommands carry
requires_confirm: truein policy regardless of lockout state.git push --force,aws iam delete-*, anything destructive in a configurable set. Mac-side coily sends a notification with allow/deny actions. Lockout answers "stop everything," confirms answer "approve this specific one." Both wanted.8. HMAC details
~/.coily/keychmod 600. Bootstrap is the only time the key crosses sides, after that it never moves.HMAC-SHA256(key, canonical(body))withhmacfield omitted from input. Canonicalization is JCS or equivalent deterministic JSON serialization.HMAC's value is provenance, not authorization. It catches stray writes from outside coily (Dropbox sync, editor plugins, second tools dropping files in the request path) and protects against tampering between write and read. It does not protect against a compromised coily process. Authorization at the request level (policy allowlists, rate limits, confirms) is what catches misbehavior.
Build order: ship without HMAC, add it the moment you notice a second writer in
.coily/requests/. Trigger is real noise, not hypothetical threats.Client coverage
~/projects/coilysirento write descriptors into. The shared filesystem primitive does not exist.If Code sessions ever need to participate, the move is to relocate the trust point off the Mac onto an always-on tailnet host. Same shape, different transport, different host. Ongoing cost of maintaining that host is its own decision.
Phone-side entry points (Android)
Primary: Termux widget calls a script. Script is one ssh line.
No public surface, mutual auth via tailnet identity. Voice trigger via Google Assistant routine plus Tasker is one extra hop. "Hey Google, coily lockout." Voice latency is the only cost.
Fallback channel for when Tailscale is wedged: a Gmail label watcher on the Mac. Send an email with a specific label or to a specific alias, label triggers a launchd-watched flag file. Latency is Gmail polling, seconds to minutes. Slow but reliable when nothing else works.
Mac-side hotkey
Hammerspoon (or Karabiner): hold control-option-P for 2 seconds, fires
coily lockout. Useful when at the Mac and not in a terminal.File layout summary
Sandbox-side coily reads its HMAC key from
~/.coily/keyinside the sandbox (not in the mount). Bootstrap copies it once at provisioning, never again. Lockout rotation invalidates the sandbox's copy.Implementation order
bin/coily-linux-arm64. Sandbox-sidecoily --helpshould work.make install-launchagent._executor_drainsubcommand. FIFO queue, exclusive lock, no policy yet (allow-all). Wire end to end forcoily gh issue create. Get the round trip working.ghonly. Allowlist of subcommand args, allowlisted env vars.Steps 1-5 are the meat. Steps 6-9 are policy. Steps 10-13 harden.
Open questions and tradeoffs
principalfield..coily/requests/and refuse anything older than N minutes withstale-on-recovery.Why we ended up here
This design started as "how do I let Claude file a GitHub issue on a private repo" and grew, deliberately, into "how do coily-wrapped privileged operations work for any agent context that has filesystem access to the Mac." The original problem is solved by the first invocation:
coily gh issue createfrom a Cowork sandbox produces a real issue oncoilysiren/coilyco-aiwithout the sandbox ever seeing a token. Everything else here is the framework that makes adding the next privileged op (and the one after that, and the lockout button, and the mobile widget) cheap.