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