Class: Pikuri::Code::Bash::Sandbox::FullFsNoNet
- Inherits:
-
Object
- Object
- Pikuri::Code::Bash::Sandbox::FullFsNoNet
- 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/shadowunreadable — privileged edits needsudo, 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/devomits the block nodes (+/dev/nvme0n1+) and turns +smartctl+'s honestEACCESinto a confusingENOENT, corrupting the model's error signal (full argument inpikuri-code/DESIGN.md).--dev-bind, never plain--bind, for both:--bindappliesMS_NODEV, which leaves nodes visible to +ls+/+stat+ but unopenable (+echo x > /dev/null+ ⇒EACCESon acrw-rw-rw-file). It grants no authority the caller lacks unsandboxed — node permissions and the uid map still apply. -
--proc /procover the bound root. PID is not unshared (see Isolation), so/procstill 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 =
bwrapflags 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_ADDRESSoutside both dirs gets its own/dev/nullmask.Load-bearing for the egress cut, not hygiene: a reachable session bus spawns commands outside every namespace, so
--unshare-netwithout 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 inpikuri-code/DESIGN.md; the capability trade inDECISIONS.mdD_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
-
#confined_to_workspace? ⇒ Boolean
false—--dev-bind / /hands the subprocess the whole real root, whatever the workspace was scoped to. -
#egress? ⇒ Boolean
false— and thefalseis earned by SESSION_IPC_MASKS as much as by--unshare-net. -
#initialize(filesystem:) ⇒ FullFsNoNet
constructor
A new instance of FullFsNoNet.
-
#wrap(argv) ⇒ Array<String>
bwrap+ full-root dev-bind + the real /dev + fresh /proc + SESSION_IPC_MASKS +--unshare-net+ isolation +argv, ready for Subprocess.spawn.
Constructor Details
#initialize(filesystem:) ⇒ FullFsNoNet
Returns a new instance of FullFsNoNet.
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.
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.
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.
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 |