Class: Pikuri::Code::Bash::Sandbox::Bubblewrap
- Inherits:
-
Object
- Object
- Pikuri::Code::Bash::Sandbox::Bubblewrap
- Defined in:
- lib/pikuri/code/bash/sandbox.rb
Overview
Bubblewrap (+bwrap+(1)) filesystem sandbox for the bash subprocess:
composes a bwrap argv from the supplied Workspace plus a curated
OS-runtime baseline, so the subprocess sees only project + toolchain +
ephemeral temp + the few /etc files needed for TLS/DNS/tz/hostname.
Keeps the network (+--share-net+); contains the filesystem. Its
inverse is FullFsNoNet. Full posture comparison + threat model in
pikuri-code/DESIGN.md.
What's bound
- SYSTEM_ROOTS (+/lib+
/lib64/bin/sbin) and ETC_BASELINE —--ro-bind. Not in Workspace#readable (the LLM has no business grepping/sbin), but the subprocess needs them for the dynamic linker, standard utilities, and the TLS/DNS/tz/hostname handshake./usrand/optarrive via Workspace#readable instead (added by ToolchainPaths.readable). /tmp— bound to Workspace::Filesystem#temp when set (so the LLM's reflexive/tmpwrites persist across bash calls), else--tmpfs. The host's/tmpis never exposed. Synthetic/proc(+--unshare-pid+) and/dev(null/zero/random/tty) round it out.workspace.writable—--bind(read+write, persistent).workspace.readable— read-write ephemeral overlay (host dir the read-through lower, a per-session upper under<internal_temp>/overlay-<slug>/absorbs writes), sogem install/bundle/mvn/gradle/cargo/pipsucceed while the host toolchain stays untouched. Falls back to--ro-bindwithout overlayfs-in-userns (Linux < 5.11), in which case a write into a read-only toolchain dir failsEROFS(surfaced as the bash observation); this degrades with a logged warning, never raises. Why the mounts are narrow cache subdirs (secrets stay out of the sandbox's view): ToolchainPaths. Warm-cache lifetime + theEBUSYconcurrency argument:pikuri-code/DESIGN.md.
Isolation
--unshare-all --share-net: PID/mount/IPC/user/UTS unshared (can't
see host processes, mount on the host, or ptrace); the network is
kept shared because bash routinely needs +git pull+/+mvn+/+gem
install+/+curl+. --die-with-parent --new-session: dies with pikuri,
own session group (no terminal-control bleed).
Blast-radius containment, not a malware boundary — the non-defenses
(project source, poisoned deps, network exfil) and the container-as-
outer-boundary escape hatch are in pikuri-code/DESIGN.md.
Constant Summary collapse
- LOGGER =
Pikuri.logger_for('Sandbox')
- SYSTEM_ROOTS =
System-root dirs the subprocess needs that aren't in Workspace#readable. Each is +--ro-bind+'d if it exists on the host; missing entries are skipped silently (older or unusual layouts).
%w[/lib /lib64 /bin /sbin].freeze
- ETC_BASELINE =
/etcfile allowlist for the subprocess. Each is +--ro-bind+'d if it exists on the host. Nothing else from/etcis exposed — noshadow, no SSH config, no NetworkManager state. %w[ /etc/ssl /etc/ca-certificates /etc/pki /etc/resolv.conf /etc/nsswitch.conf /etc/localtime /etc/hosts ].freeze
- DENIED_CONTAINER_SOCKETS =
Container / VM control sockets that, if reachable from inside the sandbox, give the bash subprocess a one-step path to root-equivalent host access (the Docker daemon honors +docker run --privileged -v / /host+; same for containerd, CRI-O, rootful podman, buildkit, libvirt, LXD). The pikuri default workspace never exposes
/varor/run, so these are unreachable by default; #reject_container_socket_exposure! guards the configuration surface — a downstream binary adding the docker socket toworkspace.writablewould unknowingly hand the LLM the keys.Rootless variants under
$XDG_RUNTIME_DIR//run/user/$UID/are computed at class-load time. Not exhaustive; covers the engines most likely on a Linux dev box. A downstream host can subclass and extend. begin xdg_runtime = ENV['XDG_RUNTIME_DIR'] || "/run/user/#{Process.uid}" paths = %w[ /var/run/docker.sock /run/docker.sock /var/run/containerd/containerd.sock /run/containerd/containerd.sock /var/run/crio/crio.sock /run/crio/crio.sock /run/podman/podman.sock /var/run/podman/podman.sock /run/buildkit/buildkitd.sock /var/run/buildkit/buildkitd.sock /var/run/libvirt/libvirt-sock /run/libvirt/libvirt-sock /var/lib/lxd/unix.socket /var/snap/lxd/common/lxd/unix.socket ] paths.concat([ "#{xdg_runtime}/docker.sock", "#{xdg_runtime}/podman/podman.sock" ]) paths.map { |p| Pathname.new(p) }.uniq.freeze end
Instance Method Summary collapse
-
#confined_to_workspace? ⇒ Boolean
true— #initialize refuses a workspace listing/as writable, so the bind set really is a subset of the host. -
#egress? ⇒ Boolean
true— this sandbox contains the filesystem but keeps--share-net, which +gem+/+bundle+/+mvn+/+pip+ need. -
#initialize(filesystem:) ⇒ Bubblewrap
constructor
A new instance of Bubblewrap.
-
#wrap(argv) ⇒ Array<String>
bwrap+ isolation flags + bind-mounts +argv, ready for Subprocess.spawn.
Constructor Details
#initialize(filesystem:) ⇒ Bubblewrap
Returns a new instance of Bubblewrap.
206 207 208 209 210 211 212 |
# File 'lib/pikuri/code/bash/sandbox.rb', line 206 def initialize(filesystem:) @filesystem = filesystem reject_unbounded_workspace! reject_unaliased_temp! reject_container_socket_exposure! check_bwrap! end |
Instance Method Details
#confined_to_workspace? ⇒ Boolean
Returns true — #initialize refuses a workspace listing
/ as writable, so the bind set really is a subset of the host.
228 |
# File 'lib/pikuri/code/bash/sandbox.rb', line 228 def confined_to_workspace? = true |
#egress? ⇒ Boolean
Returns true — this sandbox contains the filesystem but
keeps --share-net, which +gem+/+bundle+/+mvn+/+pip+ need.
224 |
# File 'lib/pikuri/code/bash/sandbox.rb', line 224 def egress? = true |
#wrap(argv) ⇒ Array<String>
Returns bwrap + isolation flags + bind-mounts +
argv, ready for Subprocess.spawn.
218 219 220 |
# File 'lib/pikuri/code/bash/sandbox.rb', line 218 def wrap(argv) [BWRAP_BINARY, *bwrap_args, *argv] end |