pikuri-code 0.0.3 → 0.0.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5c2e283890b53d73bb46cfdf5abc198c5a09a908f77b17265dd3ba64f61b12c
4
- data.tar.gz: 59d87ce5b21e976cd1a1a6852fa80325eafbc17c5e224f7ba1aa8d6e17acefec
3
+ metadata.gz: 9f5ef764ca0b64689046aa2d9230756fa5a8790dda18f14085313952a0daec6e
4
+ data.tar.gz: bec19693ccb0f6a57a77b11a244f340e60e02713e68002ca6a02110c7ca75e25
5
5
  SHA512:
6
- metadata.gz: e16f07b2c46aca5cfc660153aa6ba857cf7ce6a3ba4f38e19f0986da16d1c4e0bd62fcba6fe38587a5dda5594c2fc18ec0086fd436e1b5122d2a57876f0a9f19
7
- data.tar.gz: 5f3a42c1f84d033baaff24a2455615620007eca32c6bafc65868ead653a9495dfe4af0da11f712322a0a7abc92ac590277d65af42fd27f1ed4b9cfc475d05444
6
+ metadata.gz: c9655d7b469fc10d028df2c0acc658efe22167f9f2ee3824be3f7f763f60fa84df6784262d3705d79757496f7c3aa4fdf45b44647998cfad72e4dcfff97f8fc9
7
+ data.tar.gz: d2cee3d1a42c27741a2493d8f4360b1e0b47920bbec6dbbce3f4833ffd07304be61a445b023ff8db1b8dd3bade4ddea00266f31b61d48f41d2be4613e310929e
data/README.md CHANGED
@@ -8,8 +8,10 @@ deliberately small so you can read the sources in an evening.
8
8
  Adds the shell-and-dev-loop layer on top of
9
9
  [`pikuri-workspace`](../pikuri-workspace/README.md)'s filesystem
10
10
  tools:
11
- - `Pikuri::Tool::Bash` — runs commands via the
12
- `Pikuri::Subprocess.spawn` chokepoint with `Confirmer` gating.
11
+ - `Pikuri::Code::Bash` — runs commands via the
12
+ `Pikuri::Subprocess.spawn` chokepoint with `Confirmer` gating,
13
+ optionally wrapped in a `Pikuri::Code::Bash::Sandbox::Bubblewrap`
14
+ filesystem sandbox.
13
15
  - `bin/pikuri-code` — the interactive coding-agent binary that
14
16
  wires file + shell + web tools into an agent rooted at the
15
17
  current working directory.
@@ -21,7 +23,10 @@ system prompt and a different toolset: `read`, `write`, `edit`,
21
23
  Sub-agents are enabled, and any
22
24
  [Agent Skills](https://agentskills.io/specification) discovered
23
25
  under `.pikuri/skills`, `.claude/skills`, or `.agents/skills`
24
- (project or home) get exposed to the model on demand.
26
+ (project or home) get exposed to the model on demand. An
27
+ in-memory task list (`task_create` / `task_in_progress` /
28
+ `task_completed` / `task_delete`) is also wired in — see
29
+ [`pikuri-tasks`](../pikuri-tasks/README.md) for the rationale.
25
30
 
26
31
  ## Install
27
32
 
@@ -94,3 +99,16 @@ having a shell. The sandboxing story is a known gap and tracked
94
99
  as future work (see [`IDEAS.md`](../IDEAS.md)); until it lands,
95
100
  **assume the agent can do anything your user can do**, and
96
101
  approve prompts on that basis.
102
+
103
+ ## Further reading
104
+
105
+ - **Narrative walkthrough:** [chapter 8 of the pikuri
106
+ guide](../docs/guide/08-code.md) — the three new seams
107
+ (`Workspace` / `Confirmer` / `Sandbox`), the full wiring block
108
+ with all four extensions, the privilege-separated `FILE_MINER` and
109
+ `GIT_REPO_RESEARCHER` personas in action; the threat-model
110
+ accounting then continues in
111
+ [chapter 9](../docs/guide/09-security-revisited.md).
112
+ - **API reference:** browse the YARD docs at
113
+ <https://rubydoc.info/gems/pikuri-code> (once published), or run
114
+ `bundle exec yard` in this directory for a local copy.
@@ -0,0 +1,556 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+ require 'set'
6
+
7
+ module Pikuri
8
+ module Code
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.
16
+ #
17
+ # == Why a seam, not a flag on Bash
18
+ #
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.
29
+ #
30
+ # == The contract
31
+ #
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.
37
+ 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.
42
+ module NONE
43
+ # @param argv [Array<String>]
44
+ # @return [Array<String>] argv unchanged
45
+ def self.wrap(argv)
46
+ argv
47
+ end
48
+ end
49
+
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+ → +--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 single
101
+ # +at_exit+. 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+.
163
+ #
164
+ # == Isolation
165
+ #
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
175
+ #
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+.
200
+ class Bubblewrap
201
+ BWRAP_BINARY = 'bwrap'
202
+
203
+ # System-root dirs the subprocess needs that aren't in
204
+ # {Workspace#readable}. Each is +--ro-bind+'d if it exists on
205
+ # the host; missing entries are skipped silently (older or
206
+ # unusual layouts).
207
+ SYSTEM_ROOTS = %w[/lib /lib64 /bin /sbin].freeze
208
+
209
+ # +/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.
213
+ ETC_BASELINE = %w[
214
+ /etc/ssl
215
+ /etc/ca-certificates
216
+ /etc/pki
217
+ /etc/resolv.conf
218
+ /etc/nsswitch.conf
219
+ /etc/localtime
220
+ /etc/hosts
221
+ ].freeze
222
+
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.
239
+ #
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.
245
+ DENIED_CONTAINER_SOCKETS = begin
246
+ xdg_runtime = ENV['XDG_RUNTIME_DIR'] || "/run/user/#{Process.uid}"
247
+ paths = %w[
248
+ /var/run/docker.sock
249
+ /run/docker.sock
250
+ /var/run/containerd/containerd.sock
251
+ /run/containerd/containerd.sock
252
+ /var/run/crio/crio.sock
253
+ /run/crio/crio.sock
254
+ /run/podman/podman.sock
255
+ /var/run/podman/podman.sock
256
+ /run/buildkit/buildkitd.sock
257
+ /var/run/buildkit/buildkitd.sock
258
+ /var/run/libvirt/libvirt-sock
259
+ /run/libvirt/libvirt-sock
260
+ /var/lib/lxd/unix.socket
261
+ /var/snap/lxd/common/lxd/unix.socket
262
+ ]
263
+ paths.concat([
264
+ "#{xdg_runtime}/docker.sock",
265
+ "#{xdg_runtime}/podman/podman.sock"
266
+ ])
267
+ paths.map { |p| Pathname.new(p) }.uniq.freeze
268
+ end
269
+
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
302
+ reject_unbounded_workspace!
303
+ reject_unaliased_temp!
304
+ reject_overlay_outside_readable!
305
+ reject_container_socket_exposure!
306
+ check_bwrap!
307
+ end
308
+
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}.
314
+ def wrap(argv)
315
+ [BWRAP_BINARY, *bwrap_args, *argv]
316
+ end
317
+
318
+ private
319
+
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.
326
+ def reject_unbounded_workspace!
327
+ return unless @workspace.writable.include?(Pathname.new('/').realpath)
328
+
329
+ raise "Code::Bash::Sandbox::Bubblewrap: workspace lists '/' as " \
330
+ 'writable (likely Workspace::Filesystem::AllowAll). ' \
331
+ 'Bubblewrap exists to contain the subprocess to a subset ' \
332
+ 'of the filesystem, which is moot when the whole filesystem ' \
333
+ 'is the workspace. Pass Sandbox::NONE instead.'
334
+ end
335
+
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.
343
+ def reject_unaliased_temp!
344
+ return if @workspace.temp.nil?
345
+ return if @workspace.alias_tmp_to_temp
346
+
347
+ raise 'Code::Bash::Sandbox::Bubblewrap: workspace has temp set ' \
348
+ 'but alias_tmp_to_temp is off. This sandbox binds ' \
349
+ 'workspace.temp at /tmp inside the subprocess, which ' \
350
+ 'requires the workspace to rewrite /tmp/* in the file ' \
351
+ 'tools so bash and file tools agree on one path. ' \
352
+ 'Construct the workspace with alias_tmp_to_temp: true, ' \
353
+ 'or pass Sandbox::NONE if you do not want the bind.'
354
+ end
355
+
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).
370
+ def reject_container_socket_exposure!
371
+ exposed = []
372
+ roots = (@workspace.writable + @workspace.readable).uniq
373
+ DENIED_CONTAINER_SOCKETS.each do |sock|
374
+ root = roots.find do |r|
375
+ r == sock || sock.to_s.start_with?(r.to_s + File::SEPARATOR)
376
+ end
377
+ exposed << [root, sock] if root
378
+ end
379
+ return if exposed.empty?
380
+
381
+ details = exposed.map { |r, s| " - #{s} (via workspace root #{r})" }.join("\n")
382
+ raise "Code::Bash::Sandbox::Bubblewrap: workspace would expose " \
383
+ "container/VM control sockets to the sandbox:\n#{details}\n\n" \
384
+ "Reaching any of these from inside the sandbox is a one-step " \
385
+ "root escape (e.g. 'docker run --privileged -v / /host'). " \
386
+ 'Remove the offending paths from workspace.readable / ' \
387
+ 'workspace.writable, or pass Sandbox::NONE if you genuinely ' \
388
+ 'intend the agent to drive a container daemon.'
389
+ end
390
+
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
+
410
+ 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
419
+ unless result.status.success?
420
+ raise "Code::Bash::Sandbox::Bubblewrap: bwrap probe failed " \
421
+ "(exit #{result.status.exitstatus}). Is user-namespace " \
422
+ 'support enabled in the kernel? Pass --no-sandbox to skip.'
423
+ end
424
+
425
+ check_overlay! unless @ephemeral_overlay.empty?
426
+ rescue Errno::ENOENT
427
+ raise "Code::Bash::Sandbox::Bubblewrap: 'bwrap' not found on PATH. " \
428
+ 'Install bubblewrap (apt-get install bubblewrap / dnf install ' \
429
+ 'bubblewrap / pacman -S bubblewrap) or pass --no-sandbox.'
430
+ end
431
+
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.
438
+ #
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?
458
+
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.'
465
+ end
466
+
467
+ def bwrap_args
468
+ args = []
469
+ mounted = Set.new
470
+ overlay_set = @ephemeral_overlay.map(&:to_s).to_set
471
+
472
+ # 1. OS-runtime baseline — NOT in workspace by design.
473
+ (SYSTEM_ROOTS + ETC_BASELINE).each do |p|
474
+ next unless File.exist?(p)
475
+ next if mounted.include?(p)
476
+ args.concat(['--ro-bind', p, p])
477
+ mounted << p
478
+ end
479
+
480
+ # 2. Synthetic /proc + /dev. /tmp is handled separately
481
+ # below — when the workspace has a temp dir we bind it
482
+ # at /tmp (so the LLM's reflexive /tmp writes persist
483
+ # across bash calls); otherwise we fall back to tmpfs.
484
+ args.concat(['--proc', '/proc', '--dev', '/dev'])
485
+
486
+ if @workspace.temp
487
+ args.concat(['--bind', @workspace.temp.to_s, '/tmp'])
488
+ mounted << '/tmp'
489
+ else
490
+ args.concat(['--tmpfs', '/tmp'])
491
+ mounted << '/tmp'
492
+ end
493
+
494
+ # 3. Workspace-derived mounts. Writable wins on overlap
495
+ # (writable ⊆ readable in the Workspace constructor;
496
+ # 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|
501
+ s = p.to_s
502
+ next if mounted.include?(s)
503
+ args.concat(['--bind', s, s])
504
+ mounted << s
505
+ end
506
+ @workspace.readable.each do |p|
507
+ s = p.to_s
508
+ next if mounted.include?(s)
509
+ args.concat(overlay_set.include?(s) ? overlay_mount_args(s) : ['--ro-bind', s, s])
510
+ mounted << s
511
+ end
512
+
513
+ # 4. Isolation + chdir.
514
+ args.concat([
515
+ '--unshare-all', '--share-net',
516
+ '--die-with-parent', '--new-session',
517
+ '--chdir', @workspace.project_root.to_s
518
+ ])
519
+ args
520
+ end
521
+
522
+ # 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 + at_exit 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.
529
+ #
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.
543
+ def overlay_mount_args(path)
544
+ slug = path.gsub(/[^A-Za-z0-9._-]/, '_').sub(/\A_+/, '')
545
+ overlay_dir = @workspace.internal_temp + "overlay-#{slug}"
546
+ upper = overlay_dir + 'upper'
547
+ work = overlay_dir + 'work'
548
+ FileUtils.mkdir_p(upper)
549
+ FileUtils.mkdir_p(work)
550
+ ['--overlay-src', path, '--overlay', upper.to_s, work.to_s, path]
551
+ end
552
+ end
553
+ end
554
+ end
555
+ end
556
+ end