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 +4 -4
- data/README.md +21 -3
- data/lib/pikuri/code/bash/sandbox.rb +556 -0
- data/lib/pikuri/{tool → code}/bash.rb +54 -34
- data/lib/pikuri/code/git_clone.rb +200 -0
- data/lib/pikuri/code/git_repo_researcher.rb +66 -0
- data/lib/pikuri/code/toolchain_paths.rb +140 -0
- data/lib/pikuri-code.rb +25 -16
- data/prompts/coding-system-prompt.txt +1 -1
- data/prompts/persona-git-repo-researcher.txt +17 -0
- metadata +47 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f5ef764ca0b64689046aa2d9230756fa5a8790dda18f14085313952a0daec6e
|
|
4
|
+
data.tar.gz: bec19693ccb0f6a57a77b11a244f340e60e02713e68002ca6a02110c7ca75e25
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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::
|
|
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
|