Class: Pikuri::Code::Bash::Sandbox::Bubblewrap

Inherits:
Object
  • Object
show all
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. /usr and /opt arrive via Workspace#readable instead (added by ToolchainPaths.readable).
  • /tmp — bound to Workspace::Filesystem#temp when set (so the LLM's reflexive /tmp writes persist across bash calls), else --tmpfs. The host's /tmp is 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), so gem install / bundle / mvn / gradle / cargo / pip succeed while the host toolchain stays untouched. Falls back to --ro-bind without overlayfs-in-userns (Linux < 5.11), in which case a write into a read-only toolchain dir fails EROFS (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 + the EBUSY concurrency 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 =

/etc file allowlist for the subprocess. Each is +--ro-bind+'d if it exists on the host. Nothing else from /etc is exposed — no shadow, 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 /var or /run, so these are unreachable by default; #reject_container_socket_exposure! guards the configuration surface — a downstream binary adding the docker socket to workspace.writable would 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

Constructor Details

#initialize(filesystem:) ⇒ Bubblewrap

Returns a new instance of Bubblewrap.

Parameters:

  • filesystem (Pikuri::Workspace::Filesystem)

    source of the per-host readable/writable roots, the chdir target, and the parent of the per-session overlay state (Workspace::Filesystem#internal_temp). Every readable dir is mounted as a read-write ephemeral overlay (or --ro-bind without overlayfs-in-userns); see the class header.

Raises:

  • (RuntimeError)

    if / is writable (pass NONE instead), if temp is set but alias_tmp_to_temp isn't, if any path equals or contains a DENIED_CONTAINER_SOCKETS entry, or if bwrap is missing / fails its basic probe. A separate overlayfs probe failure does NOT raise — it degrades to read-only binds.



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.

Returns:

  • (Boolean)

    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.

Returns:

  • (Boolean)

    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.

Parameters:

  • argv (Array<String>)

    the timeout … bash -c <cmd> argv Pikuri::Code::Bash.run would have spawned unmediated.

Returns:

  • (Array<String>)

    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