pikuri-code 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,226 +7,131 @@ require 'set'
7
7
  module Pikuri
8
8
  module Code
9
9
  class Bash
10
- # Filesystem-sandbox seam for the bash tool. {Bash} runs +bash -c
11
- # <command>+ unmediated by default ({NONE}, identity wrap); host
12
- # binaries that want isolation pass {Bubblewrap.new(workspace:)+}
13
- # to get a +bwrap+-wrapped subprocess whose filesystem view is
14
- # constrained to the {Workspace}'s readable/writable roots plus a
15
- # curated OS-runtime baseline.
10
+ # Filesystem-sandbox seam for the bash tool. A sandbox responds to
11
+ # +#wrap(argv) → Array<String>+, transforming the +timeout bash -c
12
+ # <cmd>+ argv {Bash.run} would spawn into the argv actually spawned:
13
+ # {NONE} returns it unchanged (the default), {Bubblewrap} prepends
14
+ # +bwrap+ + bind/isolation flags, {FullFsNoNet} is Bubblewrap's inverse.
16
15
  #
17
- # == Why a seam, not a flag on Bash
16
+ # Distinct from Workspace, deliberately: Workspace is "what the LLM
17
+ # observes via Read/Write/Edit/Grep/Glob"; Sandbox is "what the executed
18
+ # subprocess sees." They overlap on project + toolchain dirs but diverge
19
+ # on the OS-runtime baseline ({Bubblewrap::ETC_BASELINE} — TLS certs,
20
+ # DNS, tz, hosts): the LLM has no need to {Read} +/etc/resolv.conf+, the
21
+ # +curl+ subprocess does. Separate objects keep each allowlist with its
22
+ # owner.
18
23
  #
19
- # The pure layered design: Workspace is "what the LLM observes via
20
- # Read/Write/Edit/Grep/Glob"; Sandbox is "what the executed
21
- # subprocess sees in its filesystem view." They overlap on the
22
- # project + toolchain dirs, but diverge on the OS-runtime baseline
23
- # ({Bubblewrap::ETC_BASELINE} — TLS certs, DNS resolver config, tz
24
- # data, hosts file). The LLM has no need to {Read} +/etc/resolv.conf+;
25
- # the +curl+ subprocess does. Keeping the two concerns in distinct
26
- # objects lets Workspace stay focused on the LLM-side allowlist while
27
- # Bubblewrap owns the runtime-side allowlist + the +bwrap+-specific
28
- # argv composition.
24
+ # The full two-sandbox design each posture's threat model, the
25
+ # overlay/concurrency reasoning, and which to pick — is in
26
+ # +pikuri-code/DESIGN.md+.
29
27
  #
30
- # == The contract
28
+ # == Posture predicates
31
29
  #
32
- # A sandbox responds to +#wrap(argv) Array<String>+, transforming
33
- # the +timeout ... bash -c <cmd>+ argv that {Bash.run} would have
34
- # spawned into the actual argv to spawn. {NONE} returns +argv+
35
- # unchanged; {Bubblewrap} prepends +bwrap+ + its bind/isolation
36
- # flags.
30
+ # A sandbox also answers two questions about itself, which is how {Bash}
31
+ # computes its {Pikuri::Tool::TrifectaLegs} asking the object rather
32
+ # than reading a flag out of the argv, because the argv is exactly where
33
+ # that reading goes wrong:
34
+ #
35
+ # * +#egress?+ — can the wrapped command reach a network sink?
36
+ # * +#confined_to_workspace?+ — does it see only the workspace?
37
+ #
38
+ # Both are **conservative at the identity element**: {NONE} answers
39
+ # +true+/+false+ respectively, so a host that subclasses one of these
40
+ # (the {Bubblewrap} header invites it) inherits an answer that is wrong
41
+ # only in the safe direction. A sandbox that could ever *degrade* to a
42
+ # networked run rather than raising must answer +egress? == true+, and a
43
+ # severed network namespace alone is not evidence of a severed leg — a
44
+ # reachable session bus spawns the fetch outside every namespace (see
45
+ # {FullFsNoNet::SESSION_IPC_MASKS}).
37
46
  module Sandbox
38
- # Identity sandbox passthrough. Default for {Bash.new}, used
39
- # when the host opts out via +--no-sandbox+ / +--yolo+, and the
40
- # natural baseline for tests and any non-coding binary that
41
- # invokes {Bash} directly.
47
+ # The +bwrap+(1) binary, resolved on +PATH+.
48
+ BWRAP_BINARY = 'bwrap'
49
+
50
+ # Run a one-shot +bwrap+ probe and return the finished process: spawns
51
+ # +bwrap <flags> --die-with-parent /bin/true+ at +/+ and waits.
52
+ #
53
+ # Sandbox.probe('--bind', '/', '/', '--unshare-net').status.success?
54
+ #
55
+ # +flags+ are the isolation each sandbox promises (+--unshare-net+, an
56
+ # overlay mount, …) — deliberately *not* defaulted, because the probe's
57
+ # whole job is to prove *that exact* isolation works on this host (see
58
+ # +pikuri-code/DESIGN.md+ on why the two probes aren't interchangeable).
59
+ # Propagates +Errno::ENOENT+ when +bwrap+ is absent; each caller rescues
60
+ # it to raise its own install hint.
61
+ #
62
+ # @param flags [Array<String>] isolation flags under test.
63
+ # @return [Pikuri::Subprocess::Result] query +.status.success?+ /
64
+ # +.status.exitstatus+.
65
+ # @raise [Errno::ENOENT] if +bwrap+ isn't on +PATH+.
66
+ def self.probe(*flags)
67
+ Pikuri::Subprocess.spawn(
68
+ BWRAP_BINARY, *flags, '--die-with-parent', '/bin/true', chdir: '/'
69
+ ).wait
70
+ end
71
+
72
+ # Identity sandbox — passthrough. The {Bash.new} default and the
73
+ # +--no-sandbox+ / +--yolo+ opt-out.
42
74
  module NONE
43
75
  # @param argv [Array<String>]
44
76
  # @return [Array<String>] argv unchanged
45
77
  def self.wrap(argv)
46
78
  argv
47
79
  end
80
+
81
+ # @return [Boolean] +true+ — an unwrapped command has the host's
82
+ # network
83
+ def self.egress? = true
84
+
85
+ # @return [Boolean] +false+ — an unwrapped command has the host's
86
+ # whole filesystem, whatever the workspace was scoped to
87
+ def self.confined_to_workspace? = false
48
88
  end
49
89
 
50
- # Bubblewrap (+bwrap+(1)) sandbox: composes a +bwrap+ argv from
51
- # the supplied {Workspace} plus a curated OS-runtime baseline,
52
- # so the bash subprocess sees only the project + toolchain +
53
- # ephemeral temp + the few +/etc+ files needed for TLS, DNS,
54
- # timezone, and name resolution.
55
- #
56
- # == What's bound, and why
57
- #
58
- # * {SYSTEM_ROOTS} — +/lib+, +/lib64+, +/bin+, +/sbin+
59
- # (often symlinks to +/usr+ on modern distros). Not in
60
- # {Workspace#readable} (the LLM has no business grepping
61
- # +/sbin/+), but the subprocess needs them executable for the
62
- # dynamic linker + standard utilities. +/usr+ and +/opt+ are
63
- # *not* listed here because they already come in via
64
- # {Workspace#readable} (added by
65
- # +Pikuri::Code::ToolchainPaths.readable+).
66
- # * {ETC_BASELINE} — +/etc/ssl+, +/etc/ca-certificates+,
67
- # +/etc/pki+, +/etc/resolv.conf+, +/etc/nsswitch.conf+,
68
- # +/etc/localtime+, +/etc/hosts+. Allowlist (not the whole
69
- # +/etc+!) of the files +bash+ subprocesses commonly need —
70
- # TLS handshake, DNS, timezone, hostname resolution. Nothing
71
- # sensitive (no +shadow+, no +ssh_config+, no NetworkManager
72
- # state).
73
- # * +/tmp+ — when {Workspace::Filesystem#temp} is set, bound
74
- # to the workspace temp dir (so the LLM's reflexive +/tmp+
75
- # writes land in a persistent dir that survives between bash
76
- # calls). When no workspace temp is wired in, falls back to
77
- # +--tmpfs /tmp+ (per-call ephemeral). The host's +/tmp+ is
78
- # never exposed. +/proc+ (synthetic, sees only the sandbox's
79
- # own processes due to +--unshare-pid+) and +/dev+ (synthetic,
80
- # +null+/+zero+/+random+/+tty+ only) round out the synthetic
81
- # mounts.
82
- # * +workspace.readable+ → mounted as a *read-write ephemeral
83
- # overlay* each (when the kernel supports overlayfs in a user
84
- # namespace; +--ro-bind+ fallback otherwise — see "Overlay
85
- # support" below). The host's real dir is the lower
86
- # (read-through), and a per-session upper + workdir under
87
- # +<workspace.internal_temp>/overlay-<slug>/+ absorb writes.
88
- # Result: the bash subprocess sees a fully read-write view of
89
- # +/usr+, +~/.rbenv+, +~/.gradle/caches+, +~/.m2/repository+,
90
- # …, so +gem install+ / +bundle install+ / +mvn+ / +gradle+ /
91
- # +cargo+ / +pip+ all succeed; the host's real toolchain and
92
- # caches are untouched; and on process exit the umbrella (and
93
- # with it every upper layer) is removed by the workspace's
94
- # {Pikuri::Finalizers} registration. Within one pikuri-code
95
- # session writes survive across bash calls (warm cache after
96
- # the first build — the upper is a persistent dir on disk,
97
- # re-mounted each call, *not* a tmpfs that dies with the
98
- # bwrap process); across sessions they don't (so a session
99
- # that gets prompt-injected into poisoning the in-sandbox
100
- # view of gradle's cache or a toolchain binary cannot
101
- # propagate the damage to the host's normal +gradle+
102
- # invocations or to a future pikuri-code session). Note: the
103
- # cache entries in {Pikuri::Code::ToolchainPaths} are
104
- # deliberately *narrow* subdirs (e.g. +~/.gradle/caches+, not
105
- # +~/.gradle+) so +gradle.properties+ / +init.d+ /
106
- # +.credentials+ never reach the sandbox at all — the lower
107
- # layer is the host's real file, so a narrow mount, not the
108
- # write's ephemerality, is what keeps secrets out. See
109
- # {Pikuri::Code::ToolchainPaths} for the credential /
110
- # persistence exclusion rationale.
111
- # * +workspace.writable+ → +--bind+ (read+write, *persistent* —
112
- # not an overlay) each path. The workspace temp's host path
113
- # (under +~/.cache/pikuri+, not under +/tmp+) is bound at its
114
- # host path too — so the same dir is reachable via both
115
- # +/tmp+ (LLM reflex) and the host path (advertised by the
116
- # system prompt, used consistently by the file tools off the
117
- # host filesystem).
118
- #
119
- # == Overlay support
120
- #
121
- # overlayfs in a user namespace needs Linux ≥ 5.11. The
122
- # constructor probes for it once and remembers the result:
123
- # when supported, +workspace.readable+ dirs are overlaid (the
124
- # writes-just-work path above); when not, they fall back to
125
- # +--ro-bind+ and the sandbox still functions — but writes into
126
- # a read-only toolchain dir (e.g. +gem install+ into
127
- # +~/.rbenv+) fail with +EROFS+, surfaced to the LLM as the
128
- # bash observation. The probe degrades with a logged warning
129
- # rather than raising, so an old-kernel host still gets a
130
- # working (if less convenient) sandbox. {SYSTEM_ROOTS} and
131
- # {ETC_BASELINE} stay +--ro-bind+ regardless — nothing writes
132
- # to +/lib+ or +/etc/resolv.conf+ (and overlayfs is
133
- # directory-only, so the single-file +/etc+ entries can't be
134
- # overlays anyway).
135
- #
136
- # == Concurrency contract
137
- #
138
- # Each {Bubblewrap} instance must own its upper/workdir paths
139
- # exclusively — overlayfs returns +EBUSY+ when two live mounts
140
- # share an upper or workdir. The bundled wiring guarantees
141
- # this:
142
- #
143
- # * One {Workspace::Filesystem} mints one umbrella
144
- # ({Workspace::Filesystem#internal_temp}).
145
- # * One umbrella feeds one {Bubblewrap}, which derives its
146
- # per-path +overlay-<slug>/+ subdirs from that umbrella.
147
- # * {Bash} runs +bash -c+ synchronously
148
- # ({Pikuri::Subprocess#wait}), and sub-agents block their
149
- # parent's loop while running (the +agent+ tool from
150
- # +pikuri-subagents+ runs its child's loop synchronously
151
- # in its +execute+ closure), so two +bwrap+ invocations
152
- # spawned by the same pikuri process never overlap in time.
153
- #
154
- # Two concurrent pikuri-code processes are independent — each
155
- # mints its own umbrella, each gets its own
156
- # +overlay-<slug>/+ tree, the host's real cache (the shared
157
- # *lower* layer) is read-only and per kernel docs may be
158
- # shared across overlay mounts without restriction. A
159
- # downstream host that builds something fan-out-y (e.g. N
160
- # parallel shell tasks reusing one {Bubblewrap}) would
161
- # collide on its own; pikuri itself doesn't.
162
- #
163
- # == What the overlay does NOT defend
90
+ # Bubblewrap (+bwrap+(1)) filesystem sandbox for the bash subprocess:
91
+ # composes a +bwrap+ argv from the supplied {Workspace} plus a curated
92
+ # OS-runtime baseline, so the subprocess sees only project + toolchain +
93
+ # ephemeral temp + the few +/etc+ files needed for TLS/DNS/tz/hostname.
94
+ # Keeps the network (+--share-net+); contains the filesystem. Its
95
+ # inverse is {FullFsNoNet}. Full posture comparison + threat model in
96
+ # +pikuri-code/DESIGN.md+.
164
97
  #
165
- # Bubblewrap as a whole is *blast-radius containment* for the
166
- # bash subprocess, not a malware-resistant boundary. Prompt
167
- # injection that reaches the LLM can still:
98
+ # == What's bound
168
99
  #
169
- # * Modify project source under +project_root+ (the LLM
170
- # legitimately needs Write access there overlay isn't an
171
- # option without breaking the agent).
172
- # * Inject a malicious dependency in the project's
173
- # +build.gradle.kts+/+pom.xml+/+package.json+, which the next
174
- # build will execute.
175
- # * Exfiltrate over the network +--share-net+ is intentional
176
- # so +git pull+ / +mvn+ / +gem install+ / +curl+ work.
177
- #
178
- # The overlay specifically prevents *cross-project*
179
- # contamination via shared $HOME caches. Users who need
180
- # adversarial isolation run pikuri-code inside a container /
181
- # devcontainer; the container is the outer boundary, the
182
- # bwrap sandbox is the inner one. See CLAUDE.md "Scope
183
- # decisions" / "Workspace seam" and the matching note on
184
- # +Filesystem::AllowAll+.
100
+ # * {SYSTEM_ROOTS} (+/lib+ +/lib64+ +/bin+ +/sbin+) and {ETC_BASELINE}
101
+ # +--ro-bind+. Not in {Workspace#readable} (the LLM has no business
102
+ # grepping +/sbin+), but the subprocess needs them for the dynamic
103
+ # linker, standard utilities, and the TLS/DNS/tz/hostname handshake.
104
+ # +/usr+ and +/opt+ arrive via {Workspace#readable} instead (added by
105
+ # {Pikuri::Code::ToolchainPaths.readable}).
106
+ # * +/tmp+ bound to {Workspace::Filesystem#temp} when set (so the
107
+ # LLM's reflexive +/tmp+ writes persist across bash calls), else
108
+ # +--tmpfs+. The host's +/tmp+ is never exposed. Synthetic +/proc+
109
+ # (+--unshare-pid+) and +/dev+ (null/zero/random/tty) round it out.
110
+ # * +workspace.writable+ +--bind+ (read+write, *persistent*).
111
+ # * +workspace.readable+ read-write *ephemeral overlay* (host dir the
112
+ # read-through lower, a per-session upper under
113
+ # +<internal_temp>/overlay-<slug>/+ absorbs writes), so +gem install+
114
+ # / +bundle+ / +mvn+ / +gradle+ / +cargo+ / +pip+ succeed while the
115
+ # host toolchain stays untouched. Falls back to +--ro-bind+ without
116
+ # overlayfs-in-userns (Linux < 5.11), in which case a write into a
117
+ # read-only toolchain dir fails +EROFS+ (surfaced as the bash
118
+ # observation); this degrades with a logged warning, never raises.
119
+ # Why the mounts are narrow cache subdirs (secrets stay out of the
120
+ # sandbox's *view*): {Pikuri::Code::ToolchainPaths}. Warm-cache
121
+ # lifetime + the +EBUSY+ concurrency argument: +pikuri-code/DESIGN.md+.
185
122
  #
186
123
  # == Isolation
187
124
  #
188
- # +--unshare-all --share-net+: PID, mount, IPC, user, and UTS
189
- # namespaces are unshared (the sandbox can't see host
190
- # processes, can't mount on the host, can't ptrace, …); the
191
- # network namespace is *kept* shared because the agent's bash
192
- # routinely needs +git pull+, +mvn+, +gem install+, +curl+, etc.
193
- # +--die-with-parent --new-session+: subprocess dies with
194
- # pikuri, in its own session group (no terminal control bleed).
195
- #
196
- # == Failures that surface at construction
197
- #
198
- # The constructor probes the workspace shape, then +bwrap+ with a
199
- # no-op invocation. Three cases raise loudly:
200
- #
201
- # * Workspace lists +/+ as writable (typically
202
- # {Workspace::Filesystem::AllowAll}) — Bubblewrap exists for
203
- # filesystem containment, which is structurally meaningless
204
- # when the whole filesystem is the workspace. The host should
205
- # pass {NONE} instead.
206
- # * Workspace has +temp+ but +alias_tmp_to_temp+ is off —
207
- # inconsistent setup: this sandbox would bind +workspace.temp+
208
- # at +/tmp+ inside the subprocess (so the LLM's reflexive
209
- # +/tmp+ writes persist), but file tools running on the host
210
- # would still reject +/tmp/foo+ as outside the workspace.
211
- # The LLM would write via bash and then fail to read via the
212
- # file tools; fail at construction instead of letting that
213
- # trap fire mid-conversation.
214
- # * +bwrap+ not on +PATH+ → +Errno::ENOENT+ wrapped as +RuntimeError+.
215
- # * Kernel lacks user-namespace support entirely (some hardened
216
- # distros) → the basic +bwrap+ probe exits non-zero, surfaced
217
- # as +RuntimeError+.
218
- #
219
- # The constructor *also* probes overlayfs support, but a failure
220
- # there does NOT raise — it degrades to read-only binds with a
221
- # logged warning (see "Overlay support" above). A working
222
- # sandbox on an old kernel beats no sandbox at all.
125
+ # +--unshare-all --share-net+: PID/mount/IPC/user/UTS unshared (can't
126
+ # see host processes, mount on the host, or ptrace); the network is
127
+ # *kept* shared because bash routinely needs +git pull+/+mvn+/+gem
128
+ # install+/+curl+. +--die-with-parent --new-session+: dies with pikuri,
129
+ # own session group (no terminal-control bleed).
223
130
  #
224
- # The raising cases fail at boot, not on the first +bash+ tool
225
- # call matches the "errors are loud" convention. The host
226
- # opts out of sandboxing entirely via +--no-sandbox+ / +--yolo+.
131
+ # Blast-radius containment, *not* a malware boundary the non-defenses
132
+ # (project source, poisoned deps, network exfil) and the container-as-
133
+ # outer-boundary escape hatch are in +pikuri-code/DESIGN.md+.
227
134
  class Bubblewrap
228
- BWRAP_BINARY = 'bwrap'
229
-
230
135
  LOGGER = Pikuri.logger_for('Sandbox')
231
136
 
232
137
  # System-root dirs the subprocess needs that aren't in
@@ -236,9 +141,8 @@ module Pikuri
236
141
  SYSTEM_ROOTS = %w[/lib /lib64 /bin /sbin].freeze
237
142
 
238
143
  # +/etc+ file allowlist for the subprocess. Each is +--ro-bind+'d
239
- # if it exists on the host. Nothing else from +/etc+ is
240
- # exposed — no +shadow+, no +passwd+ beyond what +/etc/hosts+
241
- # touches, no SSH config, no NetworkManager state.
144
+ # if it exists on the host. Nothing else from +/etc+ is exposed —
145
+ # no +shadow+, no SSH config, no NetworkManager state.
242
146
  ETC_BASELINE = %w[
243
147
  /etc/ssl
244
148
  /etc/ca-certificates
@@ -249,28 +153,20 @@ module Pikuri
249
153
  /etc/hosts
250
154
  ].freeze
251
155
 
252
- # Container / VM control sockets that, if reachable from
253
- # inside the sandbox, give the bash subprocess a one-step
254
- # path to root-equivalent host access. The Docker daemon
255
- # cheerfully honors +docker run --privileged -v / /host+,
256
- # so exposing +/var/run/docker.sock+ to a sandboxed agent
257
- # effectively undoes the sandbox. Same story for containerd,
258
- # CRI-O, podman (rootful), buildkit, libvirt, LXD.
259
- #
260
- # The pikuri default workspace doesn't expose +/var+ or
261
- # +/run+ at all (none of {SYSTEM_ROOTS}, {ETC_BASELINE}, or
262
- # {ToolchainPaths.readable} touches them), so these sockets
263
- # are unreachable by default. {.reject_container_socket_exposure!}
264
- # guards the *configuration* surface — a downstream binary
265
- # adding the docker socket to +workspace.writable+ "so the
266
- # agent can run +docker build+" would unknowingly hand the
267
- # LLM the keys, and we'd rather fail loud at construction.
156
+ # Container / VM control sockets that, if reachable from inside the
157
+ # sandbox, give the bash subprocess a one-step path to
158
+ # root-equivalent host access (the Docker daemon honors +docker run
159
+ # --privileged -v / /host+; same for containerd, CRI-O, rootful
160
+ # podman, buildkit, libvirt, LXD). The pikuri default workspace never
161
+ # exposes +/var+ or +/run+, so these are unreachable by default;
162
+ # {.reject_container_socket_exposure!} guards the *configuration*
163
+ # surface — a downstream binary adding the docker socket to
164
+ # +workspace.writable+ would unknowingly hand the LLM the keys.
268
165
  #
269
- # Rootless variants under +$XDG_RUNTIME_DIR+ /
270
- # +/run/user/$UID/+ are computed at class-load time. The
271
- # list is not exhaustive; it covers the engines most likely
272
- # to be installed on a Linux dev box. A downstream host
273
- # with an unusual setup can subclass and extend.
166
+ # Rootless variants under +$XDG_RUNTIME_DIR+ / +/run/user/$UID/+ are
167
+ # computed at class-load time. Not exhaustive; covers the engines
168
+ # most likely on a Linux dev box. A downstream host can subclass and
169
+ # extend.
274
170
  DENIED_CONTAINER_SOCKETS = begin
275
171
  xdg_runtime = ENV['XDG_RUNTIME_DIR'] || "/run/user/#{Process.uid}"
276
172
  paths = %w[
@@ -296,56 +192,48 @@ module Pikuri
296
192
  paths.map { |p| Pathname.new(p) }.uniq.freeze
297
193
  end
298
194
 
299
- # @param workspace [Pikuri::Workspace::Filesystem] the source of
300
- # per-host readable/writable roots, the +chdir+ target for
301
- # the subprocess, and the parent of the per-session
302
- # overlay state ({Workspace::Filesystem#internal_temp}).
303
- # Every +workspace.readable+ dir is mounted as a read-write
304
- # ephemeral overlay (or +--ro-bind+ if the kernel lacks
305
- # overlayfs-in-userns support); see the class header.
306
- # @raise [RuntimeError] if the workspace lists +/+ as writable
307
- # (Bubblewrap is for filesystem containment, which is moot
308
- # when the entire filesystem is the workspace typically
309
- # {Workspace::Filesystem::AllowAll}; the host should pass
310
- # {NONE} instead).
311
- # @raise [RuntimeError] if the workspace has +temp+ set but
312
- # +alias_tmp_to_temp+ unset — see the class header.
313
- # @raise [RuntimeError] if any workspace path equals or is
314
- # an ancestor of a known container/VM control socket
315
- # (+/var/run/docker.sock+, +containerd.sock+, +podman.sock+,
316
- # …); see {DENIED_CONTAINER_SOCKETS}.
317
- # @raise [RuntimeError] if +bwrap+ isn't on +PATH+ or fails
318
- # its basic probe (typically: kernel without user-namespace
319
- # support). A *separate* overlayfs probe failure does NOT
320
- # raise — it degrades to read-only binds (see the class
321
- # header's "Overlay support").
322
- def initialize(workspace:)
323
- @workspace = workspace
195
+ # @param filesystem [Pikuri::Workspace::Filesystem] source of the
196
+ # per-host readable/writable roots, the +chdir+ target, and the
197
+ # parent of the per-session overlay state
198
+ # ({Workspace::Filesystem#internal_temp}). Every +readable+ dir is
199
+ # mounted as a read-write ephemeral overlay (or +--ro-bind+ without
200
+ # overlayfs-in-userns); see the class header.
201
+ # @raise [RuntimeError] if +/+ is writable (pass {NONE} instead), if
202
+ # +temp+ is set but +alias_tmp_to_temp+ isn't, if any path equals or
203
+ # contains a {DENIED_CONTAINER_SOCKETS} entry, or if +bwrap+ is
204
+ # missing / fails its basic probe. A *separate* overlayfs probe
205
+ # failure does NOT raise — it degrades to read-only binds.
206
+ def initialize(filesystem:)
207
+ @filesystem = filesystem
324
208
  reject_unbounded_workspace!
325
209
  reject_unaliased_temp!
326
210
  reject_container_socket_exposure!
327
211
  check_bwrap!
328
212
  end
329
213
 
330
- # @param argv [Array<String>] the +timeout … bash -c <cmd>+
331
- # argv that {Bash.run} would have spawned unmediated.
332
- # @return [Array<String>] +bwrap+ + isolation flags +
333
- # bind-mounts + +argv+, ready to hand to
334
- # {Pikuri::Subprocess.spawn}.
214
+ # @param argv [Array<String>] the +timeout … bash -c <cmd>+ argv
215
+ # {Bash.run} would have spawned unmediated.
216
+ # @return [Array<String>] +bwrap+ + isolation flags + bind-mounts +
217
+ # +argv+, ready for {Pikuri::Subprocess.spawn}.
335
218
  def wrap(argv)
336
219
  [BWRAP_BINARY, *bwrap_args, *argv]
337
220
  end
338
221
 
222
+ # @return [Boolean] +true+ — this sandbox contains the filesystem but
223
+ # keeps +--share-net+, which +gem+/+bundle+/+mvn+/+pip+ need
224
+ def egress? = true
225
+
226
+ # @return [Boolean] +true+ — {#initialize} refuses a workspace listing
227
+ # +/+ as writable, so the bind set really is a subset of the host
228
+ def confined_to_workspace? = true
229
+
339
230
  private
340
231
 
341
- # Bubblewrap's whole job is filesystem containment; that's
342
- # structurally meaningless when the workspace's writable set
343
- # includes the root directory (typically because the host
344
- # wired in {Workspace::Filesystem::AllowAll}). Refuse to
345
- # construct rather than +--bind / /+ over our own
346
- # tmpfs/proc/dev layout.
232
+ # Refuse to construct when +/+ is in the writable set (typically
233
+ # {Workspace::Filesystem::AllowAll}) filesystem containment is moot
234
+ # when the whole filesystem is the workspace.
347
235
  def reject_unbounded_workspace!
348
- return unless @workspace.writable.include?(Pathname.new('/').realpath)
236
+ return unless @filesystem.writable.include?(Pathname.new('/').realpath)
349
237
 
350
238
  raise "Code::Bash::Sandbox::Bubblewrap: workspace lists '/' as " \
351
239
  'writable (likely Workspace::Filesystem::AllowAll). ' \
@@ -354,16 +242,13 @@ module Pikuri
354
242
  'is the workspace. Pass Sandbox::NONE instead.'
355
243
  end
356
244
 
357
- # When the workspace has a temp dir, this sandbox binds it at
358
- # +/tmp+ inside the subprocess so the LLM's reflexive +/tmp+
359
- # writes persist across bash calls. That bind only pays off
360
- # if the workspace also rewrites +/tmp/*+ in the file tools
361
- # via +alias_tmp_to_temp+; otherwise the LLM writes via bash
362
- # and then hits "outside workspace" on every Read/Write/Edit/
363
- # Grep/Glob against the same +/tmp/*+ path. Fail at boot.
245
+ # A temp bind at +/tmp+ only pays off if the workspace also rewrites
246
+ # +/tmp/*+ in the file tools via +alias_tmp_to_temp+; otherwise the
247
+ # LLM writes via bash then hits "outside workspace" on every file-tool
248
+ # access to the same path. Fail at boot.
364
249
  def reject_unaliased_temp!
365
- return if @workspace.temp.nil?
366
- return if @workspace.alias_tmp_to_temp
250
+ return if @filesystem.temp.nil?
251
+ return if @filesystem.alias_tmp_to_temp
367
252
 
368
253
  raise 'Code::Bash::Sandbox::Bubblewrap: workspace has temp set ' \
369
254
  'but alias_tmp_to_temp is off. This sandbox binds ' \
@@ -374,23 +259,13 @@ module Pikuri
374
259
  'or pass Sandbox::NONE if you do not want the bind.'
375
260
  end
376
261
 
377
- # A workspace path that *contains* a container/VM control
378
- # socket (e.g. a host pinning +/var/run+ to
379
- # +workspace.writable+ "so docker works") effectively
380
- # neutralizes the sandbox: from inside, +docker run
381
- # --privileged -v / /host+ is a one-step root escape. The
382
- # pikuri default workspace never exposes +/var/run+ or
383
- # +/run+, but a downstream host could; refuse loudly at
384
- # construction so the operator notices.
385
- #
386
- # The check compares each socket path against every
387
- # +workspace.writable+ / +workspace.readable+ root: a
388
- # workspace root +R+ exposes socket +S+ iff +R == S+ or
389
- # +S+ is below +R+ (so a +--bind+/+--ro-bind+ at +R+ would
390
- # carry +S+ along).
262
+ # A workspace root that equals or contains a container/VM control
263
+ # socket effectively neutralizes the sandbox (+docker run
264
+ # --privileged -v / /host+ from inside is a one-step root escape).
265
+ # A root +R+ exposes socket +S+ iff +R == S+ or +S+ is below +R+.
391
266
  def reject_container_socket_exposure!
392
267
  exposed = []
393
- roots = (@workspace.writable + @workspace.readable).uniq
268
+ roots = (@filesystem.writable + @filesystem.readable).uniq
394
269
  DENIED_CONTAINER_SOCKETS.each do |sock|
395
270
  root = roots.find do |r|
396
271
  r == sock || sock.to_s.start_with?(r.to_s + File::SEPARATOR)
@@ -409,30 +284,20 @@ module Pikuri
409
284
  'intend the agent to drive a container daemon.'
410
285
  end
411
286
 
412
- # Probes +bwrap+ itself (raises if missing or user namespaces
413
- # are unsupported), then probes overlayfs support and caches
414
- # the result in +@overlay_supported+. The overlay probe runs
415
- # only when there's at least one dir that would actually be
416
- # overlaid i.e. a +workspace.readable+ entry that isn't
417
- # already a +workspace.writable+ +--bind+ (a project-root-only
418
- # workspace overlays nothing, so the second spawn would be
419
- # wasted).
287
+ # Probe +bwrap+ (raises if missing or user namespaces are
288
+ # unsupported), then probe overlayfs and cache the verdict in
289
+ # +@overlay_supported+. The overlay probe runs only when at least one
290
+ # dir would actually be overlaid (a +readable+ entry not already a
291
+ # +writable+ +--bind+), so a project-root-only workspace skips it.
420
292
  def check_bwrap!
421
- result = Pikuri::Subprocess.spawn(
422
- BWRAP_BINARY,
423
- '--unshare-all', '--share-net',
424
- '--ro-bind', '/', '/',
425
- '--die-with-parent',
426
- '/bin/true',
427
- chdir: '/'
428
- ).wait
293
+ result = Sandbox.probe('--unshare-all', '--share-net', '--ro-bind', '/', '/')
429
294
  unless result.status.success?
430
295
  raise "Code::Bash::Sandbox::Bubblewrap: bwrap probe failed " \
431
296
  "(exit #{result.status.exitstatus}). Is user-namespace " \
432
297
  'support enabled in the kernel? Pass --no-sandbox to skip.'
433
298
  end
434
299
 
435
- overlayable = @workspace.readable - @workspace.writable
300
+ overlayable = @filesystem.readable - @filesystem.writable
436
301
  @overlay_supported = overlayable.empty? ? false : probe_overlay_support?
437
302
  rescue Errno::ENOENT
438
303
  raise "Code::Bash::Sandbox::Bubblewrap: 'bwrap' not found on PATH. " \
@@ -440,36 +305,23 @@ module Pikuri
440
305
  'bubblewrap / pacman -S bubblewrap) or pass --no-sandbox.'
441
306
  end
442
307
 
443
- # Second-stage probe: overlayfs in a user namespace requires
444
- # Linux ≥ 5.11. The basic +check_bwrap!+ probe succeeds on
445
- # older kernels too (it doesn't touch overlay), so we probe
446
- # separately here. Unlike the basic probe this does NOT raise
447
- # on failure — it logs a warning and returns +false+, and
448
- # {#bwrap_args} falls back to +--ro-bind+ for the readable
449
- # dirs. A working sandbox (toolchain dirs visible read-only,
450
- # writes failing with +EROFS+) beats no sandbox on an old
451
- # kernel; the host can still pass +--no-sandbox+ to opt out.
308
+ # Second-stage probe: overlayfs-in-userns needs Linux 5.11, which
309
+ # the basic {#check_bwrap!} probe doesn't exercise. Unlike that one
310
+ # this does NOT raise on failure — it logs a warning and returns
311
+ # +false+, and {#bwrap_args} falls back to +--ro-bind+.
452
312
  #
453
- # Uses +--overlay-src /usr --tmp-overlay /tmp+: declares
454
- # +/usr+ as the read-only lower layer (always present on
455
- # Linux, not an ancestor of +/tmp+ overlayfs forbids
456
- # ancestor relationships between layers) and lets bwrap
457
- # back the upper with tmpfs. No host paths to manage, no
458
- # leftover state, and the +--overlay-src+ is required —
459
- # +--tmp-overlay+ refuses to construct without at least one.
313
+ # Uses +--overlay-src /usr --tmp-overlay /tmp+: +/usr+ is the
314
+ # read-only lower (always present, not an ancestor of +/tmp+ —
315
+ # overlayfs forbids ancestor relationships), tmpfs backs the upper.
316
+ # No host paths to manage; +--tmp-overlay+ refuses to construct
317
+ # without at least one +--overlay-src+.
460
318
  #
461
319
  # @return [Boolean] whether overlayfs-in-userns works here.
462
320
  def probe_overlay_support?
463
- result = Pikuri::Subprocess.spawn(
464
- BWRAP_BINARY,
465
- '--unshare-all', '--share-net',
466
- '--ro-bind', '/', '/',
467
- '--overlay-src', '/usr',
468
- '--tmp-overlay', '/tmp',
469
- '--die-with-parent',
470
- '/bin/true',
471
- chdir: '/'
472
- ).wait
321
+ result = Sandbox.probe(
322
+ '--unshare-all', '--share-net', '--ro-bind', '/', '/',
323
+ '--overlay-src', '/usr', '--tmp-overlay', '/tmp'
324
+ )
473
325
  return true if result.status.success?
474
326
 
475
327
  LOGGER.warn(
@@ -500,8 +352,8 @@ module Pikuri
500
352
  # across bash calls); otherwise we fall back to tmpfs.
501
353
  args.concat(['--proc', '/proc', '--dev', '/dev'])
502
354
 
503
- if @workspace.temp
504
- args.concat(['--bind', @workspace.temp.to_s, '/tmp'])
355
+ if @filesystem.temp
356
+ args.concat(['--bind', @filesystem.temp.to_s, '/tmp'])
505
357
  mounted << '/tmp'
506
358
  else
507
359
  args.concat(['--tmpfs', '/tmp'])
@@ -516,13 +368,13 @@ module Pikuri
516
368
  # are read-write ephemeral overlays when overlayfs is
517
369
  # supported (so toolchain installs succeed but vanish at
518
370
  # session exit), falling back to --ro-bind otherwise.
519
- @workspace.writable.each do |p|
371
+ @filesystem.writable.each do |p|
520
372
  s = p.to_s
521
373
  next if mounted.include?(s)
522
374
  args.concat(['--bind', s, s])
523
375
  mounted << s
524
376
  end
525
- @workspace.readable.each do |p|
377
+ @filesystem.readable.each do |p|
526
378
  s = p.to_s
527
379
  next if mounted.include?(s)
528
380
  args.concat(@overlay_supported ? overlay_mount_args(s) : ['--ro-bind', s, s])
@@ -533,35 +385,27 @@ module Pikuri
533
385
  args.concat([
534
386
  '--unshare-all', '--share-net',
535
387
  '--die-with-parent', '--new-session',
536
- '--chdir', @workspace.project_root.to_s
388
+ '--chdir', @filesystem.project_root.to_s
537
389
  ])
538
390
  args
539
391
  end
540
392
 
541
393
  # Lazily mint +<workspace.internal_temp>/overlay-<slug>/{upper,work}+
542
- # for +path+ and return the +bwrap+ argv fragment that
543
- # mounts an overlayfs at +path+ with the host's real +path+
544
- # as the read-only lower. The umbrella + its Finalizers-driven cleanup are
545
- # owned by the workspace; touching {Workspace::Filesystem#internal_temp}
546
- # here is what triggers the lazy mint the first time any
547
- # overlay path needs storage.
548
- #
549
- # Calling this on every +wrap+ invocation is intentional:
550
- # +mkdir_p+ is idempotent, and a fresh mkdir on each call
551
- # is the cheapest way to recover from "someone wiped the
552
- # umbrella mid-session" without per-instance bookkeeping.
394
+ # for +path+ and return the +bwrap+ fragment mounting an overlayfs at
395
+ # +path+ with the host's real +path+ as the read-only lower. The
396
+ # umbrella + its Finalizers-driven cleanup are owned by the workspace;
397
+ # touching {Workspace::Filesystem#internal_temp} here triggers the
398
+ # lazy mint. Called on every +wrap+: +mkdir_p+ is idempotent and is
399
+ # the cheapest recovery from a mid-session umbrella wipe.
553
400
  #
554
- # *Concurrency:* the returned +upper+ and +work+ paths are
555
- # *not* safe to mount from two live overlay mounts at the
556
- # same time overlayfs returns +EBUSY+. This is fine in
557
- # pikuri because {Bash} serializes bash calls and sub-agents
558
- # block their parent's loop; see the class header's
559
- # "Concurrency contract" section. A downstream host that
560
- # parallelizes two bash invocations through the same
561
- # {Bubblewrap} would hit +EBUSY+ at the second mount.
401
+ # *Concurrency:* the +upper+/+work+ paths are not safe to mount from
402
+ # two live overlays at once (+EBUSY+) fine because {Bash} serializes
403
+ # bash calls; see the class header's "Concurrency". A downstream host
404
+ # parallelizing through one {Bubblewrap} would hit +EBUSY+ at the
405
+ # second mount.
562
406
  def overlay_mount_args(path)
563
407
  slug = path.gsub(/[^A-Za-z0-9._-]/, '_').sub(/\A_+/, '')
564
- overlay_dir = @workspace.internal_temp + "overlay-#{slug}"
408
+ overlay_dir = @filesystem.internal_temp + "overlay-#{slug}"
565
409
  upper = overlay_dir + 'upper'
566
410
  work = overlay_dir + 'work'
567
411
  FileUtils.mkdir_p(upper)
@@ -569,6 +413,176 @@ module Pikuri
569
413
  ['--overlay-src', path, '--overlay', upper.to_s, work.to_s, path]
570
414
  end
571
415
  end
416
+
417
+ # Full-filesystem, network-severed sandbox — the inverse of
418
+ # {Bubblewrap}. Binds the entire real root read-write (+--dev-bind / /+)
419
+ # and unshares *only* the network (+--unshare-net+). This is the sandbox
420
+ # for the offline OS-helper agent (+bin/pikuri-os+): its job is to operate
421
+ # the real host, so filesystem containment is the wrong tool and egress
422
+ # is the whole risk. With the network namespace unshared *and* the live
423
+ # session's IPC surface masked ({SESSION_IPC_MASKS} — both halves are
424
+ # load-bearing), an injection in a file the agent reads has nowhere to
425
+ # exfiltrate to — the trifecta's egress leg is cut by the kernel, not a
426
+ # prompt. Full design + threat model in +pikuri-code/DESIGN.md+.
427
+ #
428
+ # == What's bound
429
+ #
430
+ # * +--dev-bind / /+ — the entire real root, read-write, no overlays.
431
+ # Unprivileged, so +bwrap+'s user namespace maps the caller's uid to
432
+ # itself: root-owned files (+/etc/*+, another user's +$HOME+) stay
433
+ # *not* writable and +/etc/shadow+ unreadable — privileged edits need
434
+ # +sudo+, unavailable in the namespace, so they route to the human.
435
+ # * +--dev-bind /dev /dev+ — the *real* device tree, not bwrap's
436
+ # synthetic +--dev+. Redundant with the root bind but stated so it
437
+ # survives someone narrowing that bind: a synthetic +/dev+ omits the
438
+ # block nodes (+/dev/nvme0n1+) and turns +smartctl+'s honest +EACCES+
439
+ # into a confusing +ENOENT+, corrupting the model's error signal (full
440
+ # argument in +pikuri-code/DESIGN.md+).
441
+ #
442
+ # +--dev-bind+, never plain +--bind+, for both: +--bind+ applies
443
+ # +MS_NODEV+, which leaves nodes visible to +ls+/+stat+ but unopenable
444
+ # (+echo x > /dev/null+ ⇒ +EACCES+ on a +crw-rw-rw-+ file). It grants
445
+ # no authority the caller lacks unsandboxed — node permissions and the
446
+ # uid map still apply.
447
+ # * +--proc /proc+ over the bound root. PID is *not* unshared (see
448
+ # Isolation), so +/proc+ still shows host processes — essential for
449
+ # +ps+/+top+/+systemctl status+.
450
+ #
451
+ # == What's masked
452
+ #
453
+ # {SESSION_IPC_MASKS} — the user's live session sockets, the one hole
454
+ # punched back out of that full-root bind. It's what makes this agent
455
+ # *headless*: no GUI launching, by design.
456
+ #
457
+ # == Isolation: network only, deliberately
458
+ #
459
+ # +--unshare-net+ and nothing else. PID, IPC and UTS are *kept shared*
460
+ # — unlike {Bubblewrap}'s +--unshare-all+ — because an OS helper must
461
+ # see and reason about host processes and services (unsharing PID would
462
+ # blind +ps+/+systemctl+). +--die-with-parent --new-session+ for the
463
+ # same hygiene as Bubblewrap.
464
+ #
465
+ # == No fallback
466
+ #
467
+ # If +bwrap+ is missing or the kernel forbids unprivileged user/network
468
+ # namespaces, the constructor *raises* — it does NOT degrade to a
469
+ # networked run (the opposite of {Bubblewrap}'s overlay probe). Here the
470
+ # severed network IS the security property. Fix the kernel setting (e.g.
471
+ # +kernel.unprivileged_userns_clone+) or don't run this agent there.
472
+ #
473
+ # The full-root bind still exposes the *rootful* container/VM control
474
+ # sockets under +/run+ (a confirmed +docker run --network=host+ has full
475
+ # connectivity; only the rootless variants vanish with the runtime-dir
476
+ # mask), so this is not a malware boundary — it severs the agent's *own*
477
+ # egress; the non-defenses are in +pikuri-code/DESIGN.md+. A hard
478
+ # boundary means running the whole agent inside a container / VM.
479
+ class FullFsNoNet
480
+ # Directory holding the X11 display sockets.
481
+ X11_SOCKET_DIR = '/tmp/.X11-unix'
482
+
483
+ # +bwrap+ flags hiding the user's live session sockets (bus, Wayland,
484
+ # X11, keyring, ssh-agent, a rootless container daemon) from the
485
+ # sandbox. On a desktop host:
486
+ #
487
+ # ["--tmpfs", "/run/user/1000", "--tmpfs", "/tmp/.X11-unix"]
488
+ #
489
+ # Empty where there's no session — a CI container, a bare tty. A stray
490
+ # +DBUS_SESSION_BUS_ADDRESS+ outside both dirs gets its own
491
+ # +/dev/null+ mask.
492
+ #
493
+ # Load-bearing for the egress cut, not hygiene: a reachable session bus
494
+ # spawns commands *outside* every namespace, so +--unshare-net+ without
495
+ # this isn't a cut. Abstract-namespace sockets (+@/tmp/.X11-unix/X0+)
496
+ # need no mask — the unshared netns already scopes them. Measurements and
497
+ # the residual paths in +pikuri-code/DESIGN.md+; the capability trade in
498
+ # +DECISIONS.md+ +D_headless_os_agent+.
499
+ SESSION_IPC_MASKS = begin
500
+ dirs = [ENV['XDG_RUNTIME_DIR'], "/run/user/#{Process.uid}", X11_SOCKET_DIR].compact.uniq
501
+ masks = dirs.select { |d| File.directory?(d) }.flat_map { |d| ['--tmpfs', d] }
502
+ bus = ENV['DBUS_SESSION_BUS_ADDRESS']&.slice(/unix:path=([^,;]+)/, 1)
503
+ if bus && File.exist?(bus) && dirs.none? { |d| bus.start_with?("#{d}/") }
504
+ masks.concat(['--ro-bind', '/dev/null', bus])
505
+ end
506
+ masks.freeze
507
+ end
508
+
509
+ # @param filesystem [Pikuri::Workspace::Filesystem] used only for its
510
+ # +project_root+ (the +--chdir+ target); the bind set is the whole
511
+ # real root regardless, so the readable/writable lists are ignored.
512
+ # Pairs naturally with {Workspace::Filesystem::AllowAll}, which
513
+ # {Bubblewrap} rejects and this sandbox embraces.
514
+ # @raise [RuntimeError] if +bwrap+ isn't on +PATH+, or if the kernel
515
+ # refuses an unprivileged user / network namespace (so the network
516
+ # can't be severed). Deliberately no fallback — see the class header.
517
+ def initialize(filesystem:)
518
+ @filesystem = filesystem
519
+ check_bwrap!
520
+ end
521
+
522
+ # @param argv [Array<String>] the +timeout … bash -c <cmd>+ argv
523
+ # {Bash.run} would have spawned unmediated.
524
+ # @return [Array<String>] +bwrap+ + full-root dev-bind + the real /dev
525
+ # + fresh /proc + {SESSION_IPC_MASKS} + +--unshare-net+ + isolation +
526
+ # +argv+, ready for {Pikuri::Subprocess.spawn}.
527
+ def wrap(argv)
528
+ [
529
+ BWRAP_BINARY,
530
+ '--dev-bind', '/', '/',
531
+ '--dev-bind', '/dev', '/dev',
532
+ '--proc', '/proc',
533
+ *SESSION_IPC_MASKS,
534
+ '--unshare-net',
535
+ '--die-with-parent', '--new-session',
536
+ '--chdir', @filesystem.project_root.to_s,
537
+ *argv
538
+ ]
539
+ end
540
+
541
+ # +false+ — and the +false+ is earned by {SESSION_IPC_MASKS} as much
542
+ # as by +--unshare-net+. A network namespace binds *your process
543
+ # tree*, not what that tree can ask someone else to do: with the
544
+ # session bus reachable, +systemd-run --user curl+ returns HTTP 200
545
+ # from inside this sandbox. Both halves must hold, and {#initialize}
546
+ # raises rather than degrade if the kernel refuses the namespace, so
547
+ # there is no path where this answers +false+ without the severance.
548
+ #
549
+ # @return [Boolean]
550
+ def egress? = false
551
+
552
+ # @return [Boolean] +false+ — +--dev-bind / /+ hands the subprocess
553
+ # the whole real root, whatever the workspace was scoped to
554
+ def confined_to_workspace? = false
555
+
556
+ private
557
+
558
+ # Probe the exact isolation we rely on: a full-root dev-bind, the
559
+ # session-IPC masks, and a *network-unshared* namespace. Unlike
560
+ # {Bubblewrap}'s probe (which keeps +--share-net+), this MUST exercise
561
+ # +--unshare-net+ so a kernel forbidding unprivileged net namespaces
562
+ # fails here, loudly — never degrading to a networked run. +--dev-bind+
563
+ # and {SESSION_IPC_MASKS} to match {#wrap} flag-for-flag: a probe of a
564
+ # mount shape the real argv doesn't use proves nothing, and a mask this
565
+ # host refuses must fail at boot, not on the first command.
566
+ #
567
+ # @raise [RuntimeError] on probe failure or missing +bwrap+.
568
+ def check_bwrap!
569
+ result = Sandbox.probe('--dev-bind', '/', '/', *SESSION_IPC_MASKS, '--unshare-net')
570
+ return if result.status.success?
571
+
572
+ raise 'Code::Bash::Sandbox::FullFsNoNet: bwrap probe failed ' \
573
+ "(exit #{result.status.exitstatus}). The network sandbox " \
574
+ 'needs unprivileged user + network namespaces; some hardened ' \
575
+ 'kernels disable them (e.g. kernel.unprivileged_userns_clone=0). ' \
576
+ 'This sandbox does NOT fall back to a networked run — severing ' \
577
+ 'the network is the security property. Fix the kernel setting, ' \
578
+ 'or do not run the OS assistant on this host.'
579
+ rescue Errno::ENOENT
580
+ raise "Code::Bash::Sandbox::FullFsNoNet: 'bwrap' not found on PATH. " \
581
+ 'Install bubblewrap (apt-get install bubblewrap / dnf install ' \
582
+ 'bubblewrap / pacman -S bubblewrap). This agent runs every bash ' \
583
+ 'command in a network-severed namespace; there is no fallback.'
584
+ end
585
+ end
572
586
  end
573
587
  end
574
588
  end