pikuri-code 0.0.6 → 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,198 +7,132 @@ 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.
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+.
55
97
  #
56
- # == What's bound, and why
98
+ # == What's bound
57
99
  #
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+ → +--ro-bind+ each path at the same
83
- # path in the sandbox, EXCEPT paths that also appear in
84
- # +ephemeral_overlay:+ (see below).
85
- # * +workspace.writable+ → +--bind+ (read+write) each path. The
86
- # workspace temp's host path (under +~/.cache/pikuri+, not
87
- # under +/tmp+) is bound at its host path too — so the same
88
- # dir is reachable via both +/tmp+ (LLM reflex) and the host
89
- # path (advertised by the system prompt, used consistently
90
- # by the file tools off the host filesystem).
91
- # * +ephemeral_overlay+ — per-user dependency caches the
92
- # toolchain mutates (+~/.gradle/caches+, +~/.m2/repository+,
93
- # +~/.cargo/registry+, …). Each path is mounted as a
94
- # bubblewrap overlay: the host's real dir is the lower
95
- # (read-through), and a per-session upper +
96
- # workdir under +<workspace.internal_temp>/overlay-<slug>/+
97
- # absorb writes. Result: gradle/maven/cargo see a fully
98
- # read-write view of their cache, the host's real cache is
99
- # untouched, and on process exit the umbrella (and with it
100
- # every upper layer) is removed by the workspace's
101
- # {Pikuri::Finalizers} registration. Within one pikuri-code session writes survive
102
- # across bash calls (warm cache after the first build);
103
- # across sessions they don't (so a session that gets
104
- # prompt-injected into poisoning the in-sandbox view of
105
- # gradle's cache cannot propagate the damage to the host's
106
- # normal +gradle+ invocations or to a future pikuri-code
107
- # session). Note: the overlay paths are deliberately *narrow*
108
- # subdirs (e.g. +~/.gradle/caches+, not +~/.gradle+) so
109
- # +gradle.properties+ / +init.d+ / +.credentials+ never
110
- # reach the sandbox at all — see
111
- # {Pikuri::Code::ToolchainPaths} for the credential / persistence
112
- # exclusion rationale.
113
- #
114
- # == Concurrency contract
115
- #
116
- # Each {Bubblewrap} instance must own its upper/workdir paths
117
- # exclusively — overlayfs returns +EBUSY+ when two live mounts
118
- # share an upper or workdir. The bundled wiring guarantees
119
- # this:
120
- #
121
- # * One {Workspace::Filesystem} mints one umbrella
122
- # ({Workspace::Filesystem#internal_temp}).
123
- # * One umbrella feeds one {Bubblewrap}, which derives its
124
- # per-path +overlay-<slug>/+ subdirs from that umbrella.
125
- # * {Bash} runs +bash -c+ synchronously
126
- # ({Pikuri::Subprocess#wait}), and sub-agents block their
127
- # parent's loop while running (the +agent+ tool from
128
- # +pikuri-subagents+ runs its child's loop synchronously
129
- # in its +execute+ closure), so two +bwrap+ invocations
130
- # spawned by the same pikuri process never overlap in time.
131
- #
132
- # Two concurrent pikuri-code processes are independent — each
133
- # mints its own umbrella, each gets its own
134
- # +overlay-<slug>/+ tree, the host's real cache (the shared
135
- # *lower* layer) is read-only and per kernel docs may be
136
- # shared across overlay mounts without restriction. A
137
- # downstream host that builds something fan-out-y (e.g. N
138
- # parallel shell tasks reusing one {Bubblewrap}) would
139
- # collide on its own; pikuri itself doesn't.
140
- #
141
- # == What the overlay does NOT defend
142
- #
143
- # Bubblewrap as a whole is *blast-radius containment* for the
144
- # bash subprocess, not a malware-resistant boundary. Prompt
145
- # injection that reaches the LLM can still:
146
- #
147
- # * Modify project source under +project_root+ (the LLM
148
- # legitimately needs Write access there — overlay isn't an
149
- # option without breaking the agent).
150
- # * Inject a malicious dependency in the project's
151
- # +build.gradle.kts+/+pom.xml+/+package.json+, which the next
152
- # build will execute.
153
- # * Exfiltrate over the network — +--share-net+ is intentional
154
- # so +git pull+ / +mvn+ / +gem install+ / +curl+ work.
155
- #
156
- # The overlay specifically prevents *cross-project*
157
- # contamination via shared $HOME caches. Users who need
158
- # adversarial isolation run pikuri-code inside a container /
159
- # devcontainer; the container is the outer boundary, the
160
- # bwrap sandbox is the inner one. See CLAUDE.md "Scope
161
- # decisions" / "Workspace seam" and the matching note on
162
- # +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+.
163
122
  #
164
123
  # == Isolation
165
124
  #
166
- # +--unshare-all --share-net+: PID, mount, IPC, user, and UTS
167
- # namespaces are unshared (the sandbox can't see host
168
- # processes, can't mount on the host, can't ptrace, …); the
169
- # network namespace is *kept* shared because the agent's bash
170
- # routinely needs +git pull+, +mvn+, +gem install+, +curl+, etc.
171
- # +--die-with-parent --new-session+: subprocess dies with
172
- # pikuri, in its own session group (no terminal control bleed).
173
- #
174
- # == Failures that surface at construction
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).
175
130
  #
176
- # The constructor probes the workspace shape, then +bwrap+ with a
177
- # no-op invocation. Four cases raise loudly:
178
- #
179
- # * Workspace lists +/+ as writable (typically
180
- # {Workspace::Filesystem::AllowAll}) — Bubblewrap exists for
181
- # filesystem containment, which is structurally meaningless
182
- # when the whole filesystem is the workspace. The host should
183
- # pass {NONE} instead.
184
- # * Workspace has +temp+ but +alias_tmp_to_temp+ is off —
185
- # inconsistent setup: this sandbox would bind +workspace.temp+
186
- # at +/tmp+ inside the subprocess (so the LLM's reflexive
187
- # +/tmp+ writes persist), but file tools running on the host
188
- # would still reject +/tmp/foo+ as outside the workspace.
189
- # The LLM would write via bash and then fail to read via the
190
- # file tools; fail at construction instead of letting that
191
- # trap fire mid-conversation.
192
- # * +bwrap+ not on +PATH+ → +Errno::ENOENT+ wrapped as +RuntimeError+.
193
- # * Kernel lacks user-namespace support (some hardened distros)
194
- # → +bwrap+ exits non-zero, surfaced as +RuntimeError+.
195
- #
196
- # Either way the binary should fail at boot, not on the first
197
- # +bash+ tool call — matches the "errors are loud" convention.
198
- # The host opts out of sandboxing via +--no-sandbox+ /
199
- # +--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+.
200
134
  class Bubblewrap
201
- BWRAP_BINARY = 'bwrap'
135
+ LOGGER = Pikuri.logger_for('Sandbox')
202
136
 
203
137
  # System-root dirs the subprocess needs that aren't in
204
138
  # {Workspace#readable}. Each is +--ro-bind+'d if it exists on
@@ -207,9 +141,8 @@ module Pikuri
207
141
  SYSTEM_ROOTS = %w[/lib /lib64 /bin /sbin].freeze
208
142
 
209
143
  # +/etc+ file allowlist for the subprocess. Each is +--ro-bind+'d
210
- # if it exists on the host. Nothing else from +/etc+ is
211
- # exposed — no +shadow+, no +passwd+ beyond what +/etc/hosts+
212
- # 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.
213
146
  ETC_BASELINE = %w[
214
147
  /etc/ssl
215
148
  /etc/ca-certificates
@@ -220,28 +153,20 @@ module Pikuri
220
153
  /etc/hosts
221
154
  ].freeze
222
155
 
223
- # Container / VM control sockets that, if reachable from
224
- # inside the sandbox, give the bash subprocess a one-step
225
- # path to root-equivalent host access. The Docker daemon
226
- # cheerfully honors +docker run --privileged -v / /host+,
227
- # so exposing +/var/run/docker.sock+ to a sandboxed agent
228
- # effectively undoes the sandbox. Same story for containerd,
229
- # CRI-O, podman (rootful), buildkit, libvirt, LXD.
230
- #
231
- # The pikuri default workspace doesn't expose +/var+ or
232
- # +/run+ at all (none of {SYSTEM_ROOTS}, {ETC_BASELINE}, or
233
- # {ToolchainPaths.readable} touches them), so these sockets
234
- # are unreachable by default. {.reject_container_socket_exposure!}
235
- # guards the *configuration* surface — a downstream binary
236
- # adding the docker socket to +workspace.writable+ "so the
237
- # agent can run +docker build+" would unknowingly hand the
238
- # 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.
239
165
  #
240
- # Rootless variants under +$XDG_RUNTIME_DIR+ /
241
- # +/run/user/$UID/+ are computed at class-load time. The
242
- # list is not exhaustive; it covers the engines most likely
243
- # to be installed on a Linux dev box. A downstream host
244
- # 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.
245
170
  DENIED_CONTAINER_SOCKETS = begin
246
171
  xdg_runtime = ENV['XDG_RUNTIME_DIR'] || "/run/user/#{Process.uid}"
247
172
  paths = %w[
@@ -267,64 +192,48 @@ module Pikuri
267
192
  paths.map { |p| Pathname.new(p) }.uniq.freeze
268
193
  end
269
194
 
270
- # @param workspace [Pikuri::Workspace::Filesystem] the source of
271
- # per-host readable/writable roots, the +chdir+ target for
272
- # the subprocess, and the parent of the per-session
273
- # overlay state ({Workspace::Filesystem#internal_temp}).
274
- # @param ephemeral_overlay [Array<String, Pathname>] paths
275
- # (must each be a member of +workspace.readable+) to mount
276
- # as bubblewrap overlays instead of read-only binds.
277
- # Typically wired with
278
- # +Pikuri::Code::ToolchainPaths.ephemeral_overlay+. Empty
279
- # by default pure read-only baseline. See the class
280
- # header for the rationale.
281
- # @raise [RuntimeError] if the workspace lists +/+ as writable
282
- # (Bubblewrap is for filesystem containment, which is moot
283
- # when the entire filesystem is the workspace — typically
284
- # {Workspace::Filesystem::AllowAll}; the host should pass
285
- # {NONE} instead).
286
- # @raise [RuntimeError] if the workspace has +temp+ set but
287
- # +alias_tmp_to_temp+ unset — see the class header.
288
- # @raise [RuntimeError] if any +ephemeral_overlay+ path is
289
- # not also a member of +workspace.readable+ (so the LLM's
290
- # host-side file tools and the sandbox view stay
291
- # consistent on which paths are visible).
292
- # @raise [RuntimeError] if any workspace path equals or is
293
- # an ancestor of a known container/VM control socket
294
- # (+/var/run/docker.sock+, +containerd.sock+, +podman.sock+,
295
- # …); see {DENIED_CONTAINER_SOCKETS}.
296
- # @raise [RuntimeError] if +bwrap+ isn't on +PATH+ or fails
297
- # its probe (typically: kernel without user-namespace
298
- # support).
299
- def initialize(workspace:, ephemeral_overlay: [])
300
- @workspace = workspace
301
- @ephemeral_overlay = ephemeral_overlay.map { |p| Pathname.new(p).realpath }.uniq
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
302
208
  reject_unbounded_workspace!
303
209
  reject_unaliased_temp!
304
- reject_overlay_outside_readable!
305
210
  reject_container_socket_exposure!
306
211
  check_bwrap!
307
212
  end
308
213
 
309
- # @param argv [Array<String>] the +timeout … bash -c <cmd>+
310
- # argv that {Bash.run} would have spawned unmediated.
311
- # @return [Array<String>] +bwrap+ + isolation flags +
312
- # bind-mounts + +argv+, ready to hand to
313
- # {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}.
314
218
  def wrap(argv)
315
219
  [BWRAP_BINARY, *bwrap_args, *argv]
316
220
  end
317
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
+
318
230
  private
319
231
 
320
- # Bubblewrap's whole job is filesystem containment; that's
321
- # structurally meaningless when the workspace's writable set
322
- # includes the root directory (typically because the host
323
- # wired in {Workspace::Filesystem::AllowAll}). Refuse to
324
- # construct rather than +--bind / /+ over our own
325
- # 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.
326
235
  def reject_unbounded_workspace!
327
- return unless @workspace.writable.include?(Pathname.new('/').realpath)
236
+ return unless @filesystem.writable.include?(Pathname.new('/').realpath)
328
237
 
329
238
  raise "Code::Bash::Sandbox::Bubblewrap: workspace lists '/' as " \
330
239
  'writable (likely Workspace::Filesystem::AllowAll). ' \
@@ -333,16 +242,13 @@ module Pikuri
333
242
  'is the workspace. Pass Sandbox::NONE instead.'
334
243
  end
335
244
 
336
- # When the workspace has a temp dir, this sandbox binds it at
337
- # +/tmp+ inside the subprocess so the LLM's reflexive +/tmp+
338
- # writes persist across bash calls. That bind only pays off
339
- # if the workspace also rewrites +/tmp/*+ in the file tools
340
- # via +alias_tmp_to_temp+; otherwise the LLM writes via bash
341
- # and then hits "outside workspace" on every Read/Write/Edit/
342
- # 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.
343
249
  def reject_unaliased_temp!
344
- return if @workspace.temp.nil?
345
- return if @workspace.alias_tmp_to_temp
250
+ return if @filesystem.temp.nil?
251
+ return if @filesystem.alias_tmp_to_temp
346
252
 
347
253
  raise 'Code::Bash::Sandbox::Bubblewrap: workspace has temp set ' \
348
254
  'but alias_tmp_to_temp is off. This sandbox binds ' \
@@ -353,23 +259,13 @@ module Pikuri
353
259
  'or pass Sandbox::NONE if you do not want the bind.'
354
260
  end
355
261
 
356
- # A workspace path that *contains* a container/VM control
357
- # socket (e.g. a host pinning +/var/run+ to
358
- # +workspace.writable+ "so docker works") effectively
359
- # neutralizes the sandbox: from inside, +docker run
360
- # --privileged -v / /host+ is a one-step root escape. The
361
- # pikuri default workspace never exposes +/var/run+ or
362
- # +/run+, but a downstream host could; refuse loudly at
363
- # construction so the operator notices.
364
- #
365
- # The check compares each socket path against every
366
- # +workspace.writable+ / +workspace.readable+ root: a
367
- # workspace root +R+ exposes socket +S+ iff +R == S+ or
368
- # +S+ is below +R+ (so a +--bind+/+--ro-bind+ at +R+ would
369
- # 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+.
370
266
  def reject_container_socket_exposure!
371
267
  exposed = []
372
- roots = (@workspace.writable + @workspace.readable).uniq
268
+ roots = (@filesystem.writable + @filesystem.readable).uniq
373
269
  DENIED_CONTAINER_SOCKETS.each do |sock|
374
270
  root = roots.find do |r|
375
271
  r == sock || sock.to_s.start_with?(r.to_s + File::SEPARATOR)
@@ -388,86 +284,59 @@ module Pikuri
388
284
  'intend the agent to drive a container daemon.'
389
285
  end
390
286
 
391
- # Every +ephemeral_overlay+ path must also be in
392
- # +workspace.readable+ otherwise the LLM's host-side file
393
- # tools (Read/Grep/Glob, which read the real host
394
- # filesystem, not the sandbox view) would reject the same
395
- # path as outside the workspace while bash inside the
396
- # sandbox could see it through the overlay. That asymmetry
397
- # would burn an entire turn of LLM confusion every time. Fail
398
- # at construction.
399
- def reject_overlay_outside_readable!
400
- readable = @workspace.readable.to_set
401
- stray = @ephemeral_overlay.reject { |p| readable.include?(p) }
402
- return if stray.empty?
403
-
404
- raise 'Code::Bash::Sandbox::Bubblewrap: ephemeral_overlay paths ' \
405
- "#{stray.map(&:to_s).inspect} are not in workspace.readable " \
406
- '— the LLM would see one view via Read/Grep/Glob and a different ' \
407
- "view via bash. Add the path(s) to the workspace's readable: list."
408
- end
409
-
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.
410
292
  def check_bwrap!
411
- result = Pikuri::Subprocess.spawn(
412
- BWRAP_BINARY,
413
- '--unshare-all', '--share-net',
414
- '--ro-bind', '/', '/',
415
- '--die-with-parent',
416
- '/bin/true',
417
- chdir: '/'
418
- ).wait
293
+ result = Sandbox.probe('--unshare-all', '--share-net', '--ro-bind', '/', '/')
419
294
  unless result.status.success?
420
295
  raise "Code::Bash::Sandbox::Bubblewrap: bwrap probe failed " \
421
296
  "(exit #{result.status.exitstatus}). Is user-namespace " \
422
297
  'support enabled in the kernel? Pass --no-sandbox to skip.'
423
298
  end
424
299
 
425
- check_overlay! unless @ephemeral_overlay.empty?
300
+ overlayable = @filesystem.readable - @filesystem.writable
301
+ @overlay_supported = overlayable.empty? ? false : probe_overlay_support?
426
302
  rescue Errno::ENOENT
427
303
  raise "Code::Bash::Sandbox::Bubblewrap: 'bwrap' not found on PATH. " \
428
304
  'Install bubblewrap (apt-get install bubblewrap / dnf install ' \
429
305
  'bubblewrap / pacman -S bubblewrap) or pass --no-sandbox.'
430
306
  end
431
307
 
432
- # Second-stage probe: overlayfs in a user namespace requires
433
- # Linux 5.11. The basic +check_bwrap!+ above succeeds on
434
- # older kernels too (it doesn't touch overlay), so without
435
- # this stage a kernel < 5.11 would pass construction and
436
- # then fail at the *first* bash tool call with a confusing
437
- # mount error. Probe at boot, fail loud at boot.
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+.
438
312
  #
439
- # Uses +--overlay-src /usr --tmp-overlay /tmp+: declares
440
- # +/usr+ as the read-only lower layer (always present on
441
- # Linux, not an ancestor of +/tmp+ overlayfs forbids
442
- # ancestor relationships between layers) and lets bwrap
443
- # back the upper with tmpfs. No host paths to manage, no
444
- # leftover state, and the +--overlay-src+ is required —
445
- # +--tmp-overlay+ refuses to construct without at least one.
446
- def check_overlay!
447
- result = Pikuri::Subprocess.spawn(
448
- BWRAP_BINARY,
449
- '--unshare-all', '--share-net',
450
- '--ro-bind', '/', '/',
451
- '--overlay-src', '/usr',
452
- '--tmp-overlay', '/tmp',
453
- '--die-with-parent',
454
- '/bin/true',
455
- chdir: '/'
456
- ).wait
457
- return if result.status.success?
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+.
318
+ #
319
+ # @return [Boolean] whether overlayfs-in-userns works here.
320
+ def probe_overlay_support?
321
+ result = Sandbox.probe(
322
+ '--unshare-all', '--share-net', '--ro-bind', '/', '/',
323
+ '--overlay-src', '/usr', '--tmp-overlay', '/tmp'
324
+ )
325
+ return true if result.status.success?
458
326
 
459
- raise 'Code::Bash::Sandbox::Bubblewrap: overlay probe failed ' \
460
- "(exit #{result.status.exitstatus}). The bubblewrap " \
461
- 'sandbox can run but overlayfs in a user namespace is ' \
462
- 'not supported on this kernel (Linux 5.11 required). ' \
463
- 'Construct with ephemeral_overlay: [] to skip overlays, ' \
464
- 'or pass --no-sandbox to disable the sandbox entirely.'
327
+ LOGGER.warn(
328
+ "overlayfs in a user namespace is unavailable (bwrap overlay probe " \
329
+ "exit #{result.status.exitstatus}; Linux >= 5.11 required). Falling " \
330
+ 'back to read-only binds for toolchain dirs gem/bundle/pip/build ' \
331
+ 'installs into read-only toolchain dirs will fail with EROFS. Pass ' \
332
+ '--no-sandbox to disable the sandbox entirely.'
333
+ )
334
+ false
465
335
  end
466
336
 
467
337
  def bwrap_args
468
338
  args = []
469
339
  mounted = Set.new
470
- overlay_set = @ephemeral_overlay.map(&:to_s).to_set
471
340
 
472
341
  # 1. OS-runtime baseline — NOT in workspace by design.
473
342
  (SYSTEM_ROOTS + ETC_BASELINE).each do |p|
@@ -483,8 +352,8 @@ module Pikuri
483
352
  # across bash calls); otherwise we fall back to tmpfs.
484
353
  args.concat(['--proc', '/proc', '--dev', '/dev'])
485
354
 
486
- if @workspace.temp
487
- args.concat(['--bind', @workspace.temp.to_s, '/tmp'])
355
+ if @filesystem.temp
356
+ args.concat(['--bind', @filesystem.temp.to_s, '/tmp'])
488
357
  mounted << '/tmp'
489
358
  else
490
359
  args.concat(['--tmpfs', '/tmp'])
@@ -494,19 +363,21 @@ module Pikuri
494
363
  # 3. Workspace-derived mounts. Writable wins on overlap
495
364
  # (writable ⊆ readable in the Workspace constructor;
496
365
  # iterating writable first + the `mounted` guard
497
- # ensures each path is mounted once). Readable paths
498
- # that are also in @ephemeral_overlay get an overlay
499
- # mount instead of a plain --ro-bind.
500
- @workspace.writable.each do |p|
366
+ # ensures each path is mounted once). Writable paths are
367
+ # plain read+write --bind (persistent). Readable paths
368
+ # are read-write ephemeral overlays when overlayfs is
369
+ # supported (so toolchain installs succeed but vanish at
370
+ # session exit), falling back to --ro-bind otherwise.
371
+ @filesystem.writable.each do |p|
501
372
  s = p.to_s
502
373
  next if mounted.include?(s)
503
374
  args.concat(['--bind', s, s])
504
375
  mounted << s
505
376
  end
506
- @workspace.readable.each do |p|
377
+ @filesystem.readable.each do |p|
507
378
  s = p.to_s
508
379
  next if mounted.include?(s)
509
- args.concat(overlay_set.include?(s) ? overlay_mount_args(s) : ['--ro-bind', s, s])
380
+ args.concat(@overlay_supported ? overlay_mount_args(s) : ['--ro-bind', s, s])
510
381
  mounted << s
511
382
  end
512
383
 
@@ -514,35 +385,27 @@ module Pikuri
514
385
  args.concat([
515
386
  '--unshare-all', '--share-net',
516
387
  '--die-with-parent', '--new-session',
517
- '--chdir', @workspace.project_root.to_s
388
+ '--chdir', @filesystem.project_root.to_s
518
389
  ])
519
390
  args
520
391
  end
521
392
 
522
393
  # Lazily mint +<workspace.internal_temp>/overlay-<slug>/{upper,work}+
523
- # for +path+ and return the +bwrap+ argv fragment that
524
- # mounts an overlayfs at +path+ with the host's real +path+
525
- # as the read-only lower. The umbrella + its Finalizers-driven cleanup are
526
- # owned by the workspace; touching {Workspace::Filesystem#internal_temp}
527
- # here is what triggers the lazy mint the first time any
528
- # overlay path needs storage.
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.
529
400
  #
530
- # Calling this on every +wrap+ invocation is intentional:
531
- # +mkdir_p+ is idempotent, and a fresh mkdir on each call
532
- # is the cheapest way to recover from "someone wiped the
533
- # umbrella mid-session" without per-instance bookkeeping.
534
- #
535
- # *Concurrency:* the returned +upper+ and +work+ paths are
536
- # *not* safe to mount from two live overlay mounts at the
537
- # same time — overlayfs returns +EBUSY+. This is fine in
538
- # pikuri because {Bash} serializes bash calls and sub-agents
539
- # block their parent's loop; see the class header's
540
- # "Concurrency contract" section. A downstream host that
541
- # parallelizes two bash invocations through the same
542
- # {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.
543
406
  def overlay_mount_args(path)
544
407
  slug = path.gsub(/[^A-Za-z0-9._-]/, '_').sub(/\A_+/, '')
545
- overlay_dir = @workspace.internal_temp + "overlay-#{slug}"
408
+ overlay_dir = @filesystem.internal_temp + "overlay-#{slug}"
546
409
  upper = overlay_dir + 'upper'
547
410
  work = overlay_dir + 'work'
548
411
  FileUtils.mkdir_p(upper)
@@ -550,6 +413,176 @@ module Pikuri
550
413
  ['--overlay-src', path, '--overlay', upper.to_s, work.to_s, path]
551
414
  end
552
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
553
586
  end
554
587
  end
555
588
  end