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