pikuri-code 0.0.5 → 0.0.7

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: ff5d50756cde495c335c697fdc272a9cb209bf650e8265da49156d3540f9c9eb
4
- data.tar.gz: 81d0993c7e2780994f9410e62635319344b5f56cb8b1cc8f8c2e559c9fa7f8c8
3
+ metadata.gz: b7738ccd6890de1d33b779a626b5443c11bd8324711cf146cb844991c8fb8d0e
4
+ data.tar.gz: 9b4cf07b853e874231f5a3d86d7a67450569704cf12e091d4b9229e4ad29387a
5
5
  SHA512:
6
- metadata.gz: 3b3a81cff6aeb19a0e3c88cb1383e06726520e0487f6c78ddba1268ab9a0acf2bf22de8fa435bc25304debc7313e64d8a2d3c46d4397c812ca9fe1a00632b563
7
- data.tar.gz: f8fea57c6d597688a1dabe69982e8cde112f67f7474923c7bff375d77eb81679082f57258c999eaeb740801c977831c87d4cb264df40a2b8b19db5ccbaa37786
6
+ metadata.gz: b4c96ca2c0b85e75cbce666025cf3ea13507d9efdf260a1eae114b6090a742b295078ee8e44592218e3be1778b0bed6b3f853df48c86e2980fb6b333d68641e8
7
+ data.tar.gz: 56112b2b1b5cb6d0008d2cf35787a07e900532fb32e204fafc7634b9c98f34769cd9b23abfacbd8ec652b885cef7fc0b24fdce460a1d72695c7b6ac060b0941b
@@ -79,37 +79,59 @@ module Pikuri
79
79
  # own processes due to +--unshare-pid+) and +/dev+ (synthetic,
80
80
  # +null+/+zero+/+random+/+tty+ only) round out the synthetic
81
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.
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).
113
135
  #
114
136
  # == Concurrency contract
115
137
  #
@@ -174,7 +196,7 @@ module Pikuri
174
196
  # == Failures that surface at construction
175
197
  #
176
198
  # The constructor probes the workspace shape, then +bwrap+ with a
177
- # no-op invocation. Four cases raise loudly:
199
+ # no-op invocation. Three cases raise loudly:
178
200
  #
179
201
  # * Workspace lists +/+ as writable (typically
180
202
  # {Workspace::Filesystem::AllowAll}) — Bubblewrap exists for
@@ -190,16 +212,23 @@ module Pikuri
190
212
  # file tools; fail at construction instead of letting that
191
213
  # trap fire mid-conversation.
192
214
  # * +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+.
215
+ # * Kernel lacks user-namespace support entirely (some hardened
216
+ # distros) the basic +bwrap+ probe exits non-zero, surfaced
217
+ # as +RuntimeError+.
195
218
  #
196
- # Either way the binary should fail at boot, not on the first
197
- # +bash+ tool callmatches the "errors are loud" convention.
198
- # The host opts out of sandboxing via +--no-sandbox+ /
199
- # +--yolo+.
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.
223
+ #
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+.
200
227
  class Bubblewrap
201
228
  BWRAP_BINARY = 'bwrap'
202
229
 
230
+ LOGGER = Pikuri.logger_for('Sandbox')
231
+
203
232
  # System-root dirs the subprocess needs that aren't in
204
233
  # {Workspace#readable}. Each is +--ro-bind+'d if it exists on
205
234
  # the host; missing entries are skipped silently (older or
@@ -271,13 +300,9 @@ module Pikuri
271
300
  # per-host readable/writable roots, the +chdir+ target for
272
301
  # the subprocess, and the parent of the per-session
273
302
  # 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.
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.
281
306
  # @raise [RuntimeError] if the workspace lists +/+ as writable
282
307
  # (Bubblewrap is for filesystem containment, which is moot
283
308
  # when the entire filesystem is the workspace — typically
@@ -285,23 +310,19 @@ module Pikuri
285
310
  # {NONE} instead).
286
311
  # @raise [RuntimeError] if the workspace has +temp+ set but
287
312
  # +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
313
  # @raise [RuntimeError] if any workspace path equals or is
293
314
  # an ancestor of a known container/VM control socket
294
315
  # (+/var/run/docker.sock+, +containerd.sock+, +podman.sock+,
295
316
  # …); see {DENIED_CONTAINER_SOCKETS}.
296
317
  # @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: [])
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:)
300
323
  @workspace = workspace
301
- @ephemeral_overlay = ephemeral_overlay.map { |p| Pathname.new(p).realpath }.uniq
302
324
  reject_unbounded_workspace!
303
325
  reject_unaliased_temp!
304
- reject_overlay_outside_readable!
305
326
  reject_container_socket_exposure!
306
327
  check_bwrap!
307
328
  end
@@ -388,25 +409,14 @@ module Pikuri
388
409
  'intend the agent to drive a container daemon.'
389
410
  end
390
411
 
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
-
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).
410
420
  def check_bwrap!
411
421
  result = Pikuri::Subprocess.spawn(
412
422
  BWRAP_BINARY,
@@ -422,7 +432,8 @@ module Pikuri
422
432
  'support enabled in the kernel? Pass --no-sandbox to skip.'
423
433
  end
424
434
 
425
- check_overlay! unless @ephemeral_overlay.empty?
435
+ overlayable = @workspace.readable - @workspace.writable
436
+ @overlay_supported = overlayable.empty? ? false : probe_overlay_support?
426
437
  rescue Errno::ENOENT
427
438
  raise "Code::Bash::Sandbox::Bubblewrap: 'bwrap' not found on PATH. " \
428
439
  'Install bubblewrap (apt-get install bubblewrap / dnf install ' \
@@ -430,11 +441,14 @@ module Pikuri
430
441
  end
431
442
 
432
443
  # 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.
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.
438
452
  #
439
453
  # Uses +--overlay-src /usr --tmp-overlay /tmp+: declares
440
454
  # +/usr+ as the read-only lower layer (always present on
@@ -443,7 +457,9 @@ module Pikuri
443
457
  # back the upper with tmpfs. No host paths to manage, no
444
458
  # leftover state, and the +--overlay-src+ is required —
445
459
  # +--tmp-overlay+ refuses to construct without at least one.
446
- def check_overlay!
460
+ #
461
+ # @return [Boolean] whether overlayfs-in-userns works here.
462
+ def probe_overlay_support?
447
463
  result = Pikuri::Subprocess.spawn(
448
464
  BWRAP_BINARY,
449
465
  '--unshare-all', '--share-net',
@@ -454,20 +470,21 @@ module Pikuri
454
470
  '/bin/true',
455
471
  chdir: '/'
456
472
  ).wait
457
- return if result.status.success?
473
+ return true if result.status.success?
458
474
 
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.'
475
+ LOGGER.warn(
476
+ "overlayfs in a user namespace is unavailable (bwrap overlay probe " \
477
+ "exit #{result.status.exitstatus}; Linux >= 5.11 required). Falling " \
478
+ 'back to read-only binds for toolchain dirs gem/bundle/pip/build ' \
479
+ 'installs into read-only toolchain dirs will fail with EROFS. Pass ' \
480
+ '--no-sandbox to disable the sandbox entirely.'
481
+ )
482
+ false
465
483
  end
466
484
 
467
485
  def bwrap_args
468
486
  args = []
469
487
  mounted = Set.new
470
- overlay_set = @ephemeral_overlay.map(&:to_s).to_set
471
488
 
472
489
  # 1. OS-runtime baseline — NOT in workspace by design.
473
490
  (SYSTEM_ROOTS + ETC_BASELINE).each do |p|
@@ -494,9 +511,11 @@ module Pikuri
494
511
  # 3. Workspace-derived mounts. Writable wins on overlap
495
512
  # (writable ⊆ readable in the Workspace constructor;
496
513
  # 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.
514
+ # ensures each path is mounted once). Writable paths are
515
+ # plain read+write --bind (persistent). Readable paths
516
+ # are read-write ephemeral overlays when overlayfs is
517
+ # supported (so toolchain installs succeed but vanish at
518
+ # session exit), falling back to --ro-bind otherwise.
500
519
  @workspace.writable.each do |p|
501
520
  s = p.to_s
502
521
  next if mounted.include?(s)
@@ -506,7 +525,7 @@ module Pikuri
506
525
  @workspace.readable.each do |p|
507
526
  s = p.to_s
508
527
  next if mounted.include?(s)
509
- args.concat(overlay_set.include?(s) ? overlay_mount_args(s) : ['--ro-bind', s, s])
528
+ args.concat(@overlay_supported ? overlay_mount_args(s) : ['--ro-bind', s, s])
510
529
  mounted << s
511
530
  end
512
531
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rainbow'
4
-
5
3
  module Pikuri
6
4
  module Code
7
5
  # The +bash+ tool — run an arbitrary shell command in the workspace.
@@ -13,13 +11,18 @@ module Pikuri
13
11
  #
14
12
  # == Confirmation
15
13
  #
16
- # Every command requires confirmation. Bash composes the full prompt
17
- # (header line, +$ <command>+ echo, +(y/n)?+ cue) and hands it to the
18
- # confirmer; the confirmer renders + parses the answer. The command
19
- # echo passes through {.visible} which escapes control bytes so the
20
- # model can't smuggle a +\r\033[2K rm -rf ~/+ behind the displayed
21
- # text. Execution uses the raw command; only the *display* is
22
- # sanitized.
14
+ # Every command requires confirmation. Bash composes a semantic
15
+ # {Pikuri::Workspace::Confirmer::Request} question (header line)
16
+ # plus detail (+$ <command>+ echo) and hands it to the confirmer,
17
+ # which owns ALL presentation: color, the answer cue, answer
18
+ # parsing, and medium-appropriate escaping of the raw bytes (a
19
+ # terminal neutralizes control bytes, a web client HTML-escapes).
20
+ # The request deliberately carries the command verbatim so each
21
+ # renderer can escape for its own medium. The *observation* echo
22
+ # (+$ ...+ in the tool result) stays on this side of the seam and
23
+ # passes through {.visible}, so the model can't smuggle a
24
+ # +\r\033[2K rm -rf ~/+ behind the displayed text there either.
25
+ # Execution uses the raw command; only displays are sanitized.
23
26
  #
24
27
  # == Subprocess wiring
25
28
  #
@@ -34,6 +37,12 @@ module Pikuri
34
37
  # +--kill-after=5s+ gives the command 5 seconds to handle SIGTERM
35
38
  # cleanly before SIGKILL is sent.
36
39
  #
40
+ # The subprocess environment is "de-bundlerized" first — pikuri is
41
+ # usually launched under Bundler, which would otherwise leak its own
42
+ # +BUNDLE_GEMFILE+ / +RUBYOPT+ / ... into the command so a +bundle
43
+ # exec+ run against another project picks up *pikuri's* Gemfile. See
44
+ # {.subprocess_env} / {.bundler_clean_delta}.
45
+ #
37
46
  # == Timeout detection
38
47
  #
39
48
  # GNU coreutils' +timeout+ exits +124+ after a successful SIGTERM,
@@ -195,14 +204,15 @@ module Pikuri
195
204
  return "Error: timeout must be >= 1, got #{timeout}" if timeout < 1
196
205
  return "Error: timeout must be <= #{MAX_TIMEOUT}, got #{timeout}" if timeout > MAX_TIMEOUT
197
206
 
198
- prompt = compose_prompt(command: command, description: description, timeout: timeout)
199
- return 'Error: user declined the bash command.' unless confirmer.confirm?(prompt: prompt)
207
+ request = compose_request(command: command, description: description, timeout: timeout)
208
+ return 'Error: user declined the bash command.' unless confirmer.confirm?(request: request)
200
209
 
201
210
  argv = sandbox.wrap([
202
211
  'timeout', '--signal=TERM', "--kill-after=#{KILL_AFTER}", "#{timeout}s",
203
212
  'bash', '-c', command
204
213
  ])
205
- result = Pikuri::Subprocess.spawn(*argv, chdir: workspace.project_root.to_s, env: workspace.env).wait
214
+ result = Pikuri::Subprocess.spawn(*argv, chdir: workspace.project_root.to_s,
215
+ env: subprocess_env(workspace)).wait
206
216
 
207
217
  output = truncate(result.output)
208
218
  exit_code = result.status.exitstatus
@@ -217,41 +227,117 @@ module Pikuri
217
227
  end
218
228
  end
219
229
 
220
- # Compose the multi-line confirmation prompt. Three lines:
230
+ # Compose the semantic confirmation request:
221
231
  #
222
- # 1. +OK to run bash[: <desc>][ \[Timeout: Ns\]]+ (bold)
223
- # 2. +$ <visible(command)>+ (dim)
224
- # 3. +(y/n)?+
232
+ # * question — +OK to run bash[: <desc>][ \[Timeout: Ns\]]+
233
+ # * detail — +$ <command>+, the command VERBATIM (the confirmer
234
+ # escapes for its medium; see the class header's Confirmation
235
+ # section)
225
236
  #
226
237
  # Colon after +bash+ is dropped when there's no description, since a
227
238
  # trailing colon with nothing after it reads as broken. Timeout
228
239
  # suffix appears only when non-default.
229
240
  #
230
- # @return [String]
231
- def self.compose_prompt(command:, description:, timeout:)
232
- header = +'OK to run bash'
241
+ # @return [Pikuri::Workspace::Confirmer::Request]
242
+ def self.compose_request(command:, description:, timeout:)
243
+ question = +'OK to run bash'
233
244
  desc_clean = description&.strip
234
- header << ": #{desc_clean}" if desc_clean && !desc_clean.empty?
235
- header << " [Timeout: #{timeout}s]" if timeout != DEFAULT_TIMEOUT
245
+ question << ": #{desc_clean}" if desc_clean && !desc_clean.empty?
246
+ question << " [Timeout: #{timeout}s]" if timeout != DEFAULT_TIMEOUT
247
+
248
+ Pikuri::Workspace::Confirmer::Request.new(question: question, detail: "$ #{command}")
249
+ end
250
+ private_class_method :compose_request
251
+
252
+ # Environment for the bash subprocess: the workspace's git-identity
253
+ # vars ({Pikuri::Workspace::Filesystem#env}) layered over a
254
+ # "de-bundlerized" base.
255
+ #
256
+ # pikuri is typically launched under Bundler — the +bin/pikuri-code+
257
+ # demo and every downstream host (e.g. pikuri-tui) pin
258
+ # +BUNDLE_GEMFILE+ to their *own* Gemfile and +require
259
+ # 'bundler/setup'+ at boot. Doing so makes Bundler rewrite
260
+ # +BUNDLE_GEMFILE+ / +RUBYOPT+ / +GEM_HOME+ / +GEM_PATH+ / +RUBYLIB+
261
+ # / +PATH+ in the *process* environment (stashing the pre-bundler
262
+ # values under +BUNDLER_ORIG_*+). Those vars are inherited by every
263
+ # subprocess we spawn — so a +bundle exec rspec+ the agent runs in
264
+ # *another* Ruby project would resolve against PIKURI's Gemfile and
265
+ # die with a baffling "the Gemfile specifies ... is missing" error.
266
+ #
267
+ # The fix is to hand bash the environment as it was *before* bundler
268
+ # touched it (see {.bundler_clean_delta}), with the workspace's git
269
+ # identity on top.
270
+ #
271
+ # @param workspace [Pikuri::Workspace::Filesystem]
272
+ # @return [Hash{String=>(String,nil)}] +env:+ delta for
273
+ # {Pikuri::Subprocess.spawn} (a +nil+ value unsets the key in the
274
+ # child).
275
+ def self.subprocess_env(workspace)
276
+ bundler_clean_delta.merge(workspace.env)
277
+ end
278
+ private_class_method :subprocess_env
236
279
 
237
- [
238
- Rainbow(header).bold,
239
- Rainbow("$ #{visible(command)}").dimgray,
240
- '(y/n)?'
241
- ].join("\n")
280
+ # The delta that, merged onto the current (bundler-polluted) +ENV+
281
+ # via {Pikuri::Subprocess.spawn}'s +env:+, reconstructs
282
+ # +Bundler.unbundled_env+ — the environment as it was before
283
+ # +bundler/setup+ ran.
284
+ #
285
+ # +Bundler.unbundled_env+ restores each +BUNDLER_ORIG_*+ backup, so
286
+ # a host-level +GEM_HOME+/+GEM_PATH+ from a +sudo apt install ruby+
287
+ # install (pointing at +~/.gem+) passes straight through and the
288
+ # subprocess still finds the user's gems — it is *not* a blanket
289
+ # +GEM_*+ wipe — while the bundler-only vars (+BUNDLE_*+, the
290
+ # +-rbundler/setup+ +RUBYOPT+) are dropped. We diff against the live
291
+ # +ENV+ rather than passing the full hash because +spawn+'s +env:+
292
+ # is additive: changed/added keys carry their clean value, keys
293
+ # bundler injected that aren't in the clean env map to +nil+
294
+ # (+Open3.popen2e+ unsets a +nil+-valued key in the child).
295
+ #
296
+ # When pikuri runs *without* Bundler (gem-installed, or a host that
297
+ # never required +bundler/setup+) this is empty and the child
298
+ # inherits the environment unchanged.
299
+ #
300
+ # @return [Hash{String=>(String,nil)}]
301
+ def self.bundler_clean_delta
302
+ return {} unless defined?(Bundler) && Bundler.respond_to?(:unbundled_env)
303
+
304
+ env_delta(current: ENV.to_h, clean: Bundler.unbundled_env)
305
+ end
306
+ private_class_method :bundler_clean_delta
307
+
308
+ # Pure diff between two environment hashes: the additive delta that,
309
+ # applied to +current+ via {Pikuri::Subprocess.spawn}'s +env:+,
310
+ # yields +clean+. A key whose value differs (or is new in +clean+)
311
+ # carries the +clean+ value; a key present in +current+ but absent
312
+ # from +clean+ maps to +nil+ (which +Open3.popen2e+ unsets). Keys
313
+ # equal in both are omitted, keeping the delta minimal.
314
+ #
315
+ # @param current [Hash{String=>String}]
316
+ # @param clean [Hash{String=>String}]
317
+ # @return [Hash{String=>(String,nil)}]
318
+ def self.env_delta(current:, clean:)
319
+ delta = {}
320
+ clean.each { |k, v| delta[k] = v unless current[k] == v }
321
+ current.each_key { |k| delta[k] = nil unless clean.key?(k) }
322
+ delta
242
323
  end
243
- private_class_method :compose_prompt
324
+ private_class_method :env_delta
244
325
 
245
- # Escape control bytes for safe display while preserving +\n+
246
- # (multi-line shell commands are normal). Catches +\r+, +\x1b+
247
- # (ESC, the ANSI introducer), +\b+, NUL, DEL, etc. without this,
248
- # a model could craft +command: "\rrm -rf ~/"+ that visually
249
- # overwrites the echo line after the user has already read it.
326
+ # Neutralize control bytes for safe display in the *observation*
327
+ # echo (+"$ ..."+ in the tool result) without this, a model could
328
+ # craft +command: "\rrm -rf ~/"+ that visually overwrites the echo
329
+ # line after the user has already read it. Delegates to the shared
330
+ # {Pikuri::Sanitizer}, which preserves +\n+ (multi-line shell
331
+ # commands are normal) and visualizes the rest; the confirmation
332
+ # prompt routes the same command through the same primitive (see
333
+ # +Confirmer::Terminal+). The echo is passive, so the
334
+ # {Pikuri::Sanitizer::Warning}s are dropped here — they surface at
335
+ # the confirmation prompt, before the user approves.
250
336
  #
251
337
  # @param command [String]
252
338
  # @return [String]
253
339
  def self.visible(command)
254
- command.gsub(/[\x00-\x09\x0b-\x1f\x7f]/) { |c| format('\\x%02x', c.ord) }
340
+ Pikuri::Sanitizer.sanitize(command).text
255
341
  end
256
342
  private_class_method :visible
257
343
 
@@ -2,17 +2,15 @@
2
2
 
3
3
  module Pikuri
4
4
  module Code
5
- # Curated lists of filesystem prefixes a coding agent benefits
6
- # from seeing: system toolchains under +/usr+ and +/opt+, per-user
5
+ # Curated list of filesystem prefixes a coding agent benefits from
6
+ # seeing: system toolchains under +/usr+ and +/opt+, per-user
7
7
  # toolchain managers (mise/asdf/rbenv/pyenv/nvm/rustup), and the
8
8
  # per-user dependency caches the toolchains themselves mutate
9
9
  # (Gradle, Maven, Cargo, npm, pip, …). Not a tool — a
10
10
  # configuration helper that +bin/pikuri-code+ (and any downstream
11
11
  # coding binary built on pikuri-code) feeds into
12
12
  # +Pikuri::Workspace::Filesystem.new(readable: ...)+ alongside the
13
- # skill catalog's roots, and into
14
- # +Pikuri::Code::Bash::Sandbox::Bubblewrap.new(ephemeral_overlay: ...)+
15
- # for the overlay layer.
13
+ # skill catalog's roots.
16
14
  #
17
15
  # The list is the "allowlist a coding agent reads" surface derived
18
16
  # from the threat-model discussion that drove this gem; see
@@ -20,43 +18,47 @@ module Pikuri
20
18
  # containment story and CLAUDE.md's Scope decisions for the
21
19
  # Linux-first stance.
22
20
  #
23
- # == .readable vs. .ephemeral_overlay
21
+ # == One list, overlaid — not a readable-vs-writable split
24
22
  #
25
- # The split exists because the bubblewrap sandbox treats these two
26
- # groups differently:
23
+ # Earlier this module split the paths into "true read-only"
24
+ # (toolchains) and "ephemeral overlay" (caches the toolchain
25
+ # mutates). That split is gone: every entry here is mounted by
26
+ # {Pikuri::Code::Bash::Sandbox::Bubblewrap} as a *read-write
27
+ # ephemeral overlay* (when the kernel supports overlayfs in a user
28
+ # namespace; read-only bind otherwise). The host's real dir is the
29
+ # read-through lower; a per-session upper absorbs writes and is
30
+ # discarded at process exit.
27
31
  #
28
- # * {.readable} true read-only: system toolchains (+/usr+,
29
- # +/opt+) and per-user toolchain managers (+~/.rbenv+, +~/.pyenv+,
30
- # +~/.nvm+, +~/.asdf+, +~/.rustup+, +~/.local/share/mise+, +~/.config/mise+).
31
- # The user installed these out-of-band; the LLM should be able
32
- # to grep them but neither write nor *appear* to write to them.
33
- # Bubblewrap +--ro-bind+'s each.
34
- # * {.ephemeral_overlay} per-user dependency caches the
35
- # toolchain itself mutates when invoked: subdirs of +~/.gradle+,
36
- # +~/.m2/repository+, +~/.cargo/registry+, +~/.ivy2/cache+,
37
- # +~/go/pkg/mod+, +~/.cache/pip+, +~/.cache/uv+, +~/.npm+, the
38
- # pnpm store, +~/.nuget/packages+. The toolchain *needs* to
39
- # write to these (Gradle's journal/locks, Maven downloading a
40
- # new dep, …), but persistent host pollution from a poisoned
41
- # pikuri-code session would propagate to the user's other
42
- # projects. The bubblewrap sandbox overlays each with a
43
- # per-session ephemeral upper layer under
44
- # +<workspace.internal_temp>/overlay-<slug>/+ — writes survive
45
- # across bash calls within one session, then vanish at process
46
- # exit. See {Pikuri::Code::Bash::Sandbox::Bubblewrap} for the
47
- # wiring.
32
+ # The reason is that build tools assume their dirs are writable.
33
+ # A read-only +~/.rbenv+ (or mise/asdf/system Ruby) breaks
34
+ # +gem install+ / +bundle install+ with +EROFS+ — the gem install
35
+ # target lives *inside* the version-manager dir, so there's no
36
+ # separate cache to overlay. Overlaying the whole dir lets the
37
+ # install succeed against an ephemeral copy: the toolchain writes,
38
+ # the warm result survives across bash calls within the session,
39
+ # and the host's real toolchain is never touched. The same
40
+ # ephemerality that makes a writable +/usr+ safe (writes vanish at
41
+ # exit, host untouched) is what makes "the LLM must not even appear
42
+ # to write here" moot so the old read-only treatment bought
43
+ # nothing and cost a class of confusing failures.
48
44
  #
49
- # The host-side workspace continues to include both lists in its
50
- # +readable+ set, so the LLM can Read/Grep/Glob them via the file
51
- # tools (which operate on the host filesystem, not the sandbox view).
45
+ # The host-side workspace includes this list in its +readable+ set,
46
+ # so the LLM can Read/Grep/Glob the paths via the file tools (which
47
+ # operate on the host filesystem, not the sandbox view). Writes the
48
+ # LLM makes through bash land in the overlay and are *not* visible
49
+ # to the host-reading file tools — the same accepted asymmetry the
50
+ # cache overlays always had.
52
51
  #
53
52
  # == Why subdirs, not whole toolchain dirs
54
53
  #
55
- # Every entry in {.ephemeral_overlay} is a content-only subdir
56
- # chosen to *exclude* the toolchain's credential / persistence
57
- # files. The exposed path holds cache content (downloaded jars,
58
- # distributions, modules); the excluded paths hold secrets or
59
- # build-config:
54
+ # The per-user dependency caches are listed as *content-only
55
+ # subdirs* chosen to *exclude* the toolchain's credential /
56
+ # persistence files. Even though the overlay is ephemeral, the
57
+ # host's real file is the read-through lower so a narrower mount
58
+ # keeps secrets out of the sandbox's view entirely rather than
59
+ # relying on "the write vanished." The exposed path holds cache
60
+ # content (downloaded jars, distributions, modules); the excluded
61
+ # paths hold secrets or build-config:
60
62
  #
61
63
  # * +~/.gradle/caches+ + +~/.gradle/wrapper/dists+ + +~/.gradle/jdks+
62
64
  # — NOT +~/.gradle/gradle.properties+ (signing keys, OSSRH /
@@ -71,50 +73,45 @@ module Pikuri
71
73
  # (crates.io publish tokens).
72
74
  # * +~/.ivy2/cache+ — NOT +~/.ivy2/.credentials+ (resolver creds).
73
75
  #
74
- # +bwrap+ creates the parent dir (e.g. +~/.gradle/+) as an empty
75
- # tmpfs directory inside the sandbox automatically, so the
76
- # toolchain can mkdir new subdirs there (e.g. +~/.gradle/daemon/+)
77
- # without seeing anything we didn't bind. The cost is mild: dirs
78
- # outside the overlay list (+~/.gradle/daemon/+, native cache,
79
- # configuration cache) start empty each bash call instead of
80
- # persisting within a session. That's acceptable for daemon-style
81
- # caches — the warm-cache value lives in +caches/+ and +wrapper/+,
82
- # which the overlays cover.
76
+ # The version-manager roots (+~/.rbenv+, +~/.pyenv+, …) are listed
77
+ # *whole* because they don't carry credential files — their gem /
78
+ # package install targets live directly under them, so a narrower
79
+ # mount would defeat the purpose. mise installs Ruby/Node under
80
+ # +~/.local/share/mise/installs+, so overlaying +~/.local/share/mise+
81
+ # covers mise-managed +gem install+; system Ruby installs under
82
+ # +/usr+, covered by the +/usr+ overlay.
83
83
  module ToolchainPaths
84
84
  # @return [Array<String>] absolute paths, in stable order, each
85
85
  # one confirmed to be an existing directory at the moment of
86
86
  # the call. Presence-filtered: a developer who doesn't have
87
87
  # Rust installed doesn't get a phantom +~/.rustup+ in their
88
- # workspace.
88
+ # workspace, and a missing +~/.gradle/caches+ stays out (on the
89
+ # assumption the user doesn't use Gradle yet — its eventual
90
+ # bootstrap inside the sandbox without a host lower would fail
91
+ # noisily, which is what we want). The whole list is mounted as
92
+ # read-write ephemeral overlays by
93
+ # {Pikuri::Code::Bash::Sandbox::Bubblewrap}; see the module
94
+ # header for why each cache entry is a content-only subdir
95
+ # rather than the whole toolchain dir.
89
96
  def self.readable
90
97
  home = Dir.home
91
98
  candidates = [
99
+ # System + per-user toolchains. Whole dirs — no credential
100
+ # files live here, and gem/package install targets sit
101
+ # directly under them.
92
102
  '/usr',
93
103
  '/opt',
94
104
  File.join(home, '.local/share/mise'),
95
105
  File.join(home, '.config/mise'),
96
106
  File.join(home, '.asdf'),
97
107
  File.join(home, '.rbenv'),
108
+ File.join(home, '.gem'),
98
109
  File.join(home, '.pyenv'),
99
110
  File.join(home, '.nvm'),
100
- File.join(home, '.rustup')
101
- ]
102
- candidates.select { |p| File.directory?(p) }.freeze
103
- end
104
-
105
- # @return [Array<String>] absolute paths to per-user dependency
106
- # caches the toolchain mutates. Presence-filtered, same
107
- # discipline as {.readable}: a missing +~/.gradle/caches+
108
- # stays out of the list, on the assumption the user doesn't
109
- # use Gradle yet (and Gradle's eventual bootstrap inside the
110
- # sandbox without a host lower would fail noisily, which is
111
- # what we want — see the rationale in
112
- # {Pikuri::Code::Bash::Sandbox::Bubblewrap}). See the module
113
- # header for why each entry is a content-only subdir rather
114
- # than the whole toolchain dir.
115
- def self.ephemeral_overlay
116
- home = Dir.home
117
- candidates = [
111
+ File.join(home, '.rustup'),
112
+ # Per-user dependency caches the toolchain mutates. Each is a
113
+ # content-only subdir that excludes the toolchain's
114
+ # credential / persistence files — see the module header.
118
115
  File.join(home, '.cargo/registry'),
119
116
  File.join(home, '.m2/repository'),
120
117
  # Gradle: caches/ (jar + journal + transforms + build-cache),
@@ -2,8 +2,6 @@ You are an expert coding assistant who reads, edits, and runs code via tools to
2
2
 
3
3
  You operate on the local filesystem under a workspace directory. All file-touching tools resolve paths within that workspace; trying to escape returns an error. The `bash` and `write` tools may prompt the user for confirmation before running — if they decline, accept it and don't retry the same operation.
4
4
 
5
- You have access to tools described in the API's tool list. To call one, use the standard tool-call mechanism — do not write tool calls as text.
6
-
7
5
  If several next steps are independent (e.g. reading two unrelated files, or running unrelated checks), emit them as parallel tool calls in a single turn rather than one at a time.
8
6
 
9
7
  Choosing a tool:
@@ -20,6 +18,8 @@ Working on code:
20
18
  - After a substantive change, run the project's tests or build if you can locate them (look at README, package.json, Cargo.toml, Makefile, build.gradle, Gemfile, etc.).
21
19
  - Don't add ceremonial comments. Match the surrounding code's commenting style.
22
20
  - NEVER commit, push, or open a PR unless the user explicitly asks.
21
+ - When crafting git commit message, take a look at the diff instead of creating commit
22
+ message from filenames alone.
23
23
 
24
24
  Other guidelines:
25
25
  - Don't repeat a tool call with identical arguments — re-read the previous observation instead.
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pikuri-code
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.5
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martin Vysny
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-06-04 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: pikuri-core
@@ -16,56 +15,56 @@ dependencies:
16
15
  requirements:
17
16
  - - '='
18
17
  - !ruby/object:Gem::Version
19
- version: 0.0.5
18
+ version: 0.0.7
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - '='
25
24
  - !ruby/object:Gem::Version
26
- version: 0.0.5
25
+ version: 0.0.7
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: pikuri-workspace
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
30
  - - '='
32
31
  - !ruby/object:Gem::Version
33
- version: 0.0.5
32
+ version: 0.0.7
34
33
  type: :runtime
35
34
  prerelease: false
36
35
  version_requirements: !ruby/object:Gem::Requirement
37
36
  requirements:
38
37
  - - '='
39
38
  - !ruby/object:Gem::Version
40
- version: 0.0.5
39
+ version: 0.0.7
41
40
  - !ruby/object:Gem::Dependency
42
41
  name: pikuri-subagents
43
42
  requirement: !ruby/object:Gem::Requirement
44
43
  requirements:
45
44
  - - '='
46
45
  - !ruby/object:Gem::Version
47
- version: 0.0.5
46
+ version: 0.0.7
48
47
  type: :runtime
49
48
  prerelease: false
50
49
  version_requirements: !ruby/object:Gem::Requirement
51
50
  requirements:
52
51
  - - '='
53
52
  - !ruby/object:Gem::Version
54
- version: 0.0.5
53
+ version: 0.0.7
55
54
  - !ruby/object:Gem::Dependency
56
55
  name: pikuri-tasks
57
56
  requirement: !ruby/object:Gem::Requirement
58
57
  requirements:
59
58
  - - '='
60
59
  - !ruby/object:Gem::Version
61
- version: 0.0.5
60
+ version: 0.0.7
62
61
  type: :runtime
63
62
  prerelease: false
64
63
  version_requirements: !ruby/object:Gem::Requirement
65
64
  requirements:
66
65
  - - '='
67
66
  - !ruby/object:Gem::Version
68
- version: 0.0.5
67
+ version: 0.0.7
69
68
  description: |
70
69
  pikuri-code adds the shell-and-dev-loop layer on top of
71
70
  pikuri-workspace's filesystem tools: a +Pikuri::Code::Bash+ that
@@ -99,7 +98,6 @@ metadata:
99
98
  changelog_uri: https://codeberg.org/mvysny/pikuri/src/branch/master/CHANGELOG.md
100
99
  bug_tracker_uri: https://codeberg.org/mvysny/pikuri/issues
101
100
  rubygems_mfa_required: 'true'
102
- post_install_message:
103
101
  rdoc_options: []
104
102
  require_paths:
105
103
  - lib
@@ -114,8 +112,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
114
112
  - !ruby/object:Gem::Version
115
113
  version: '0'
116
114
  requirements: []
117
- rubygems_version: 3.5.22
118
- signing_key:
115
+ rubygems_version: 3.6.7
119
116
  specification_version: 4
120
117
  summary: In-repo coding-agent shell tool (Bash) + pikuri-code binary.
121
118
  test_files: []