Class: Pikuri::Code::Bash::Sandbox::FullFsNoNet

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/code/bash/sandbox.rb

Overview

Full-filesystem, network-severed sandbox — the inverse of Bubblewrap. Binds the entire real root read-write (+--dev-bind / /+) and unshares only the network (+--unshare-net+). This is the sandbox for the offline OS-helper agent (+bin/pikuri-os+): its job is to operate the real host, so filesystem containment is the wrong tool and egress is the whole risk. With the network namespace unshared and the live session's IPC surface masked (SESSION_IPC_MASKS — both halves are load-bearing), an injection in a file the agent reads has nowhere to exfiltrate to — the trifecta's egress leg is cut by the kernel, not a prompt. Full design + threat model in pikuri-code/DESIGN.md.

What's bound

  • --dev-bind / / — the entire real root, read-write, no overlays. Unprivileged, so +bwrap+'s user namespace maps the caller's uid to itself: root-owned files (+/etc/*+, another user's $HOME) stay not writable and /etc/shadow unreadable — privileged edits need sudo, unavailable in the namespace, so they route to the human.

  • --dev-bind /dev /dev — the real device tree, not bwrap's synthetic --dev. Redundant with the root bind but stated so it survives someone narrowing that bind: a synthetic /dev omits the block nodes (+/dev/nvme0n1+) and turns +smartctl+'s honest EACCES into a confusing ENOENT, corrupting the model's error signal (full argument in pikuri-code/DESIGN.md).

    --dev-bind, never plain --bind, for both: --bind applies MS_NODEV, which leaves nodes visible to +ls+/+stat+ but unopenable (+echo x > /dev/null+ ⇒ EACCES on a crw-rw-rw- file). It grants no authority the caller lacks unsandboxed — node permissions and the uid map still apply.

  • --proc /proc over the bound root. PID is not unshared (see Isolation), so /proc still shows host processes — essential for +ps+/+top+/+systemctl status+.

What's masked

SESSION_IPC_MASKS — the user's live session sockets, the one hole punched back out of that full-root bind. It's what makes this agent headless: no GUI launching, by design.

Isolation: network only, deliberately

--unshare-net and nothing else. PID, IPC and UTS are kept shared — unlike Bubblewrap's --unshare-all — because an OS helper must see and reason about host processes and services (unsharing PID would blind +ps+/+systemctl+). --die-with-parent --new-session for the same hygiene as Bubblewrap.

No fallback

If bwrap is missing or the kernel forbids unprivileged user/network namespaces, the constructor raises — it does NOT degrade to a networked run (the opposite of Bubblewrap's overlay probe). Here the severed network IS the security property. Fix the kernel setting (e.g. kernel.unprivileged_userns_clone) or don't run this agent there.

The full-root bind still exposes the rootful container/VM control sockets under /run (a confirmed docker run --network=host has full connectivity; only the rootless variants vanish with the runtime-dir mask), so this is not a malware boundary — it severs the agent's own egress; the non-defenses are in pikuri-code/DESIGN.md. A hard boundary means running the whole agent inside a container / VM.

Constant Summary collapse

X11_SOCKET_DIR =

Directory holding the X11 display sockets.

'/tmp/.X11-unix'
SESSION_IPC_MASKS =

bwrap flags hiding the user's live session sockets (bus, Wayland, X11, keyring, ssh-agent, a rootless container daemon) from the sandbox. On a desktop host:

["--tmpfs", "/run/user/1000", "--tmpfs", "/tmp/.X11-unix"]

Empty where there's no session — a CI container, a bare tty. A stray DBUS_SESSION_BUS_ADDRESS outside both dirs gets its own /dev/null mask.

Load-bearing for the egress cut, not hygiene: a reachable session bus spawns commands outside every namespace, so --unshare-net without this isn't a cut. Abstract-namespace sockets (+@/tmp/.X11-unix/X0+) need no mask — the unshared netns already scopes them. Measurements and the residual paths in pikuri-code/DESIGN.md; the capability trade in DECISIONS.md D_headless_os_agent.

begin
  dirs = [ENV['XDG_RUNTIME_DIR'], "/run/user/#{Process.uid}", X11_SOCKET_DIR].compact.uniq
  masks = dirs.select { |d| File.directory?(d) }.flat_map { |d| ['--tmpfs', d] }
  bus = ENV['DBUS_SESSION_BUS_ADDRESS']&.slice(/unix:path=([^,;]+)/, 1)
  if bus && File.exist?(bus) && dirs.none? { |d| bus.start_with?("#{d}/") }
    masks.concat(['--ro-bind', '/dev/null', bus])
  end
  masks.freeze
end

Instance Method Summary collapse

Constructor Details

#initialize(filesystem:) ⇒ FullFsNoNet

Returns a new instance of FullFsNoNet.

Parameters:

  • filesystem (Pikuri::Workspace::Filesystem)

    used only for its project_root (the --chdir target); the bind set is the whole real root regardless, so the readable/writable lists are ignored. Pairs naturally with Workspace::Filesystem::AllowAll, which Bubblewrap rejects and this sandbox embraces.

Raises:

  • (RuntimeError)

    if bwrap isn't on PATH, or if the kernel refuses an unprivileged user / network namespace (so the network can't be severed). Deliberately no fallback — see the class header.



517
518
519
520
# File 'lib/pikuri/code/bash/sandbox.rb', line 517

def initialize(filesystem:)
  @filesystem = filesystem
  check_bwrap!
end

Instance Method Details

#confined_to_workspace?Boolean

Returns false--dev-bind / / hands the subprocess the whole real root, whatever the workspace was scoped to.

Returns:

  • (Boolean)

    false--dev-bind / / hands the subprocess the whole real root, whatever the workspace was scoped to



554
# File 'lib/pikuri/code/bash/sandbox.rb', line 554

def confined_to_workspace? = false

#egress?Boolean

false — and the false is earned by SESSION_IPC_MASKS as much as by --unshare-net. A network namespace binds your process tree, not what that tree can ask someone else to do: with the session bus reachable, systemd-run --user curl returns HTTP 200 from inside this sandbox. Both halves must hold, and #initialize raises rather than degrade if the kernel refuses the namespace, so there is no path where this answers false without the severance.

Returns:

  • (Boolean)


550
# File 'lib/pikuri/code/bash/sandbox.rb', line 550

def egress? = false

#wrap(argv) ⇒ Array<String>

Returns bwrap + full-root dev-bind + the real /dev

  • fresh /proc + SESSION_IPC_MASKS + --unshare-net + isolation + 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 + full-root dev-bind + the real /dev

    • fresh /proc + SESSION_IPC_MASKS + --unshare-net + isolation + argv, ready for Subprocess.spawn.


527
528
529
530
531
532
533
534
535
536
537
538
539
# File 'lib/pikuri/code/bash/sandbox.rb', line 527

def wrap(argv)
  [
    BWRAP_BINARY,
    '--dev-bind', '/', '/',
    '--dev-bind', '/dev', '/dev',
    '--proc', '/proc',
    *SESSION_IPC_MASKS,
    '--unshare-net',
    '--die-with-parent', '--new-session',
    '--chdir', @filesystem.project_root.to_s,
    *argv
  ]
end