@evelandhq/sandbox-bwrap 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.
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 Eveland
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,232 @@
1
+ # @evelandhq/sandbox-bwrap
2
+
3
+ A [bubblewrap](https://github.com/containers/bubblewrap)-based `SandboxBackend` for
4
+ [eve](https://www.npmjs.com/package/eve) agents. It gives agent-executed code a real
5
+ Linux sandbox — actual binaries, isolated filesystem, coarse network control — without
6
+ requiring a Docker daemon or KVM.
7
+
8
+ ## Why
9
+
10
+ eve's built-in backend chain is Vercel → Docker → microsandbox → just-bash. On a
11
+ self-hosted Linux box without a Docker daemon or KVM (for example an eveland systemd
12
+ deployment host), that chain bottoms out at `just-bash`: a pure-JS interpreter with a
13
+ virtual filesystem that cannot run real binaries. This backend fills that gap with
14
+ bubblewrap, which needs nothing but the `bwrap` binary and unprivileged user
15
+ namespaces.
16
+
17
+ ## Usage
18
+
19
+ **Deployed on eveland:** you do nothing. eveland's Docker and systemd runtimes generate
20
+ the sandbox module into the release directory at build time — `agent/sandbox.js` for a flat
21
+ agent, or `agent/sandbox/sandbox.js` when a sandbox folder exists, recursively for every
22
+ subagent — and vendors this package's built output beside it, so agent projects never declare
23
+ a deployment backend themselves. If a project shipped its own sandbox module, the build
24
+ replaces that definition and reports it in the build log; authored `bootstrap()` and
25
+ `onSession()` behavior is not used. The sibling `agent/sandbox/workspace/**` tree is preserved,
26
+ so Eve still seeds those files into each Session's `/workspace`. Each Eveland Release supplies a
27
+ distinct template revision, so Sessions created against a new Deployment see its updated seeds
28
+ while existing durable Session workspaces remain untouched. The systemd runtime invokes
29
+ bwrap as its unprivileged deployment user. The local Docker runtime installs bwrap inside the Agent
30
+ image and grants the outer container only the capabilities nested bwrap requires; the
31
+ Agent container still receives no Docker socket. Local `eve dev` is untouched — it never runs
32
+ the eveland build pipeline, so it falls back to eve's default backend chain (usually
33
+ `just-bash`, or Docker where available). See eveland's `docs/deploy/linux.md` for what the
34
+ build log looks like and what happens when the sandbox does not work on the host.
35
+
36
+ **Standalone use of this package** (outside eveland, or in any project that manages its
37
+ own `agent/sandbox.ts`) still works the manual way:
38
+
39
+ ```ts
40
+ // agent/sandbox.ts
41
+ import { defineSandbox, defaultBackend } from "eve/sandbox";
42
+ import { bwrap, isBwrapAvailable } from "@evelandhq/sandbox-bwrap";
43
+
44
+ export default defineSandbox({
45
+ // bwrap on the Linux deploy host; eve's default chain everywhere else (dev laptops).
46
+ backend: () => (isBwrapAvailable() ? bwrap() : defaultBackend()),
47
+ });
48
+ ```
49
+
50
+ ### eve version requirement
51
+
52
+ This package requires `eve` `>=0.27.0 <1.0.0`.
53
+
54
+ The range is deliberately wide. eve's 0.x releases use caret-incompatible minor bumps,
55
+ so a package that pins a narrow window has to republish for every eve minor — which is
56
+ churn for consumers, not safety, when the surface actually consumed is one small
57
+ interface (`SandboxBackend` from `eve/sandbox`) that has been stable across the whole
58
+ range. Rather than re-declaring the window, CI keeps the claim honest from both ends:
59
+ `src/eve-compatibility.test.ts` typechecks the backend against the range's exact floor
60
+ (0.27.13) and the newest verified release on every run, and a scheduled workflow re-runs
61
+ the suite against `eve@latest` so a breaking eve minor shows up as a red build here
62
+ instead of a bug report from your deployment.
63
+
64
+ The backend implements the required `shutdown()` contract by killing every process the
65
+ session has spawned that has not yet exited, honoring eve's requirement that nothing may
66
+ be left running once the handle is shut down. The session's workspace directory is not
67
+ touched by `shutdown()` — it is durable state and remains available when the session
68
+ reattaches.
69
+
70
+ ### Options
71
+
72
+ | Option | Default | Meaning |
73
+ | ------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
74
+ | `env` | `{}` | Environment variables set for every sandboxed command. |
75
+ | `networkPolicy` | `"allow-all"` | `"allow-all"` shares the host network; `"deny-all"` runs each command with no network (`--unshare-net`). `setNetworkPolicy` can switch between the two at run time; granular domain policies are rejected (use the Vercel backend for those). |
76
+ | `hidePaths` | `[]` | Extra host paths hidden from the sandbox (each covered by an empty tmpfs). |
77
+ | `bwrapPath` | `"bwrap"` | bwrap executable to invoke. |
78
+ | `cacheDir` | `<appRoot>/.eve/sandbox-cache/bwrap` | Absolute directory holding templates and durable session workspaces. Pin this outside the release directory so a redeploy does not discard durable session state: since eve 0.22.0, eve keys session sandboxes per durable session, not per deployment, so an `appRoot`-derived default would silently destroy every session's `/workspace` on the next redeploy. The generated eveland module always sets this from `EVELAND_SANDBOX_CACHE_DIR`. |
79
+ | `templateRevision` | `null` | Optional immutable release identity included in the template cache key but not the session path. Change it when seed files change so new Sessions use a fresh template without overwriting durable workspaces. Eveland sets it from its internal `EVELAND_SANDBOX_TEMPLATE_REVISION`. |
80
+
81
+ ## How it works
82
+
83
+ - **prewarm** (build time): runs the authored `bootstrap` inside bwrap against a
84
+ staging directory, resolves Eve's `$HOME/.agents/skills/**` seed paths to
85
+ `/workspace/.agents/skills/**`, writes seed files, then atomically renames it into
86
+ `<cacheDir>/templates/<hash>` (`<cacheDir>` defaults to
87
+ `<appRoot>/.eve/sandbox-cache/bwrap` when the `cacheDir` option is not set). Idempotent
88
+ per template key + options hash; `templateRevision` participates in that hash.
89
+ - **create** (runtime): clones the template into `<cacheDir>/sessions/<hash>` on first
90
+ use. The directory IS the durable session state: it persists across reconnects and
91
+ process restarts.
92
+ - **run/spawn**: every command is one transient bwrap invocation —
93
+ read-only host rootfs, the session directory bound read-write at `/workspace`,
94
+ tmpfs `/tmp`, PID/IPC/UTS namespaces unshared, `--die-with-parent`.
95
+ - **File I/O** (`readTextFile`, `writeFile`, …): host-side operations on the session
96
+ directory; no subprocess. Writes outside `/workspace` are refused.
97
+
98
+ ## Disk usage and cache management
99
+
100
+ Session and template directories persist indefinitely under
101
+ `<cacheDir>/{sessions,templates}` across process restarts and reconnects, enabling fast
102
+ reattach when a session resumes. Each session key gets a directory that is reused for
103
+ the lifetime of the session; each template is cached per (template key, options hash), with
104
+ an optional release revision in the options hash,
105
+ and reused across sessions. This backend intentionally does not prune either — its
106
+ `shutdown()` method only kills the session's live processes and leaves the workspace on
107
+ disk, so reattach is instant and stateless from the agent's perspective. On a long-lived
108
+ host, this means the cache will grow with the number of durable sessions and unique
109
+ templates, consuming disk space indefinitely. On eveland deployments this cache lives at
110
+ `EVELAND_SANDBOX_CACHE_DIR` (one subdirectory per project), outside every release
111
+ directory, precisely so that redeploying a project does not touch it.
112
+
113
+ Reclaiming space today requires manual intervention: identify which sessions are known dead and delete their corresponding directories under the cache root. Automatic cache pruning (e.g., based on age or LRU) is a known gap and a planned follow-up.
114
+
115
+ ## Security boundary
116
+
117
+ - The host process environment is **never** forwarded: every invocation uses
118
+ `--clearenv` and rebuilds the environment from `PATH`, `HOME=/workspace`, `LANG`,
119
+ plus your configured `env`. Deployment secrets in the agent's `process.env` stay
120
+ out of sandboxed code.
121
+ - For code executed inside the sandbox (`run`/`spawn`), the cache root (all
122
+ other sessions and templates of the app) is hidden behind a tmpfs, so
123
+ sandboxed code cannot read sibling session state.
124
+ - The host-side read methods (`readFile`, `readBinaryFile`, `readTextFile`)
125
+ are deliberately **not** containment-checked: eve's contract requires
126
+ absolute paths to pass through to the host filesystem unchanged, so these
127
+ calls can read anything the agent process itself can read — including a
128
+ sibling session's files or a template directory — not just paths inside
129
+ `/workspace`. Only the write and remove calls (`writeFile`, `writeTextFile`,
130
+ `writeBinaryFile`, `removePath`) are confined to the workspace, via the
131
+ realpath-aware check described below. In other words, the tmpfs above is a
132
+ boundary against a _sandboxed process_, not a boundary between sessions of
133
+ the same agent — all of an app's sessions and templates share one trust
134
+ domain on the host.
135
+ - The rest of the host filesystem is _visible read-only_ to sandboxed code, and the
136
+ sandbox shares the host kernel. This is protection against mistakes and prompt
137
+ injection — not multi-tenant isolation. If untrusted tenants or code that routinely
138
+ handles customer credentials must run here, move to VM-level isolation
139
+ (Firecracker/microsandbox) instead of hardening this backend further.
140
+ Under Eveland's local Docker runtime, "host filesystem" here means the outer Agent
141
+ container's filesystem, not the Docker host; no host root or Docker socket is mounted.
142
+ - Resource limits are inherited from whatever cgroup the agent runs in (on eveland's
143
+ systemd runtime: the deployment unit's `MemoryMax`/`CPUQuota` cover sandbox
144
+ children too). The backend sets no per-command limits itself.
145
+ - Host-side write/remove calls (`writeFile`, `writeTextFile`, `writeBinaryFile`,
146
+ `removePath`) verify containment with a realpath-aware check
147
+ (`isWithinWorkspaceReal`): they resolve symlinks along the path and re-check that
148
+ the real target still lands inside the real session directory, closing the escape
149
+ where sandboxed code plants a symlink inside `/workspace` pointing outside it and a
150
+ later host-side write follows it out. A race between that check and the filesystem
151
+ call it guards remains theoretically possible — Node exposes no
152
+ `RESOLVE_BENEATH`/`O_NOFOLLOW`-atomic primitive to close it — so treat this as a
153
+ containment check against planted symlinks, not an atomic guarantee.
154
+ - Symlink resolution cannot see inode aliasing. On the kernel this backend has been
155
+ tested against (Ubuntu 24.04, aarch64), creating a hard link from inside the
156
+ sandbox to a file outside `/workspace` (`ln <host file> /workspace/x`) was
157
+ **refused** — the kernel rejected the hard link across the two bind mounts. The
158
+ integration contract test prints this as a non-assertive probe
159
+ (`HARDLINK PROBE: refused …` or `succeeded …`) rather than an assertion, because
160
+ the outcome depends on kernel/filesystem behavior this package does not control.
161
+ Do not treat hard-link rejection as a guarantee the backend enforces — verify it on
162
+ your own kernel if it matters to your threat model.
163
+
164
+ ## Requirements
165
+
166
+ Eveland's generated local Docker image installs `bubblewrap` and `bash`, creates
167
+ `/workspace`, and starts the outer Agent container with its default capability set
168
+ dropped, `SYS_ADMIN` and `NET_ADMIN` added for bwrap namespaces, `no-new-privileges`,
169
+ and `seccomp=unconfined`. This is a local-development boundary; the supported Linux
170
+ production topology uses the unprivileged systemd path below.
171
+
172
+ - Linux with unprivileged user namespaces available to the calling process. Ubuntu's
173
+ packaged bubblewrap (0.9.0-1ubuntu0.1 on 24.04) ships **no** AppArmor profile. Since
174
+ Ubuntu sets `kernel.apparmor_restrict_unprivileged_userns=1` by default, an
175
+ _unconfined non-root_ process calling `bwrap` fails with
176
+ `bwrap: setting up uid map: Permission denied` unless the host loads an AppArmor
177
+ profile that grants `bwrap` the `userns` permission. Root is unaffected by this
178
+ sysctl, but nothing here runs as root: eveland's systemd runtime runs both this
179
+ backend (as the deployment user) and its own build sandbox (as a separate,
180
+ unprivileged build user) as unconfined non-root userns creators, so both need the
181
+ same AppArmor grant. Save this as `/etc/apparmor.d/bwrap`:
182
+
183
+ ```
184
+ abi <abi/4.0>,
185
+ include <tunables/global>
186
+
187
+ profile bwrap /usr/bin/bwrap flags=(unconfined) {
188
+ userns,
189
+
190
+ # Site-specific additions and overrides. See local/README for details.
191
+ include if exists <local/bwrap>
192
+ }
193
+ ```
194
+
195
+ then load it with `apparmor_parser -r -W /etc/apparmor.d/bwrap` (safe to re-run; it
196
+ replaces an already-loaded profile). A distro whose bubblewrap package ships its own
197
+ profile, or a host with the sysctl disabled, needs none of this.
198
+
199
+ - `/workspace` must pre-exist on the host as an empty directory. `bwrap` binds each
200
+ session directory onto `/workspace` inside the sandbox but cannot create that mount
201
+ destination itself, because the host root is bind-mounted read-only first
202
+ (`bwrap: Can't mkdir /workspace: Read-only file system`). If it is missing, the
203
+ backend fails fast with an actionable error message before invoking `bwrap` (see
204
+ `describeMissingPrereqs` in `src/process.ts`).
205
+ - `bash` and (for agents that need it) `node` on the host PATH — the sandbox reuses
206
+ the host rootfs read-only.
207
+ - Works under systemd hardening (`NoNewPrivileges=yes`, `ProtectSystem=strict`):
208
+ apt's `bwrap` is not setuid, so it needs no privilege escalation to run — but it
209
+ still needs the AppArmor profile above to create a user namespace as an
210
+ unprivileged user.
211
+
212
+ ## Testing
213
+
214
+ - `pnpm test` — unit tests, run anywhere, including macOS (process execution is
215
+ injectable; no bwrap and no Linux needed). This is what CI runs on every push, and it
216
+ includes the eve floor/latest compatibility typechecks.
217
+ - `bash infra/smoke.sh` — the contract test against **real** bwrap, on macOS or Linux.
218
+ It provisions a Lima VM (`brew install lima`), streams this worktree in, and runs the
219
+ test as an unprivileged user under the systemd hardening a deployed eve agent actually
220
+ gets — `NoNewPrivileges=yes`, `ProtectSystem=strict`, `PrivateTmp=yes`. Prints
221
+ `BWRAP SMOKE OK`. Run this before pushing anything that touches `src/args.ts` or
222
+ `src/process.ts`: CI's smoke job covers the unprivileged-user case but not the systemd
223
+ constraints, and argv that looks right is not the same as a kernel that accepts it.
224
+ - `pnpm tsx src/integration/bwrap-backend-smoke.ts` — the same test, run directly. Needs
225
+ a Linux host that already has the AppArmor profile loaded and `/workspace` created
226
+ (see [Requirements](#requirements)), and should be run as an unprivileged user: root
227
+ is exempt from the userns sysctl, so a root-only pass proves nothing about a real
228
+ deployment.
229
+
230
+ ## License
231
+
232
+ Apache-2.0. See [LICENSE](./LICENSE).
package/dist/args.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /** PATH the sandbox sees; the host rootfs is visible read-only, so the standard dirs apply. */
2
+ export declare const DEFAULT_SANDBOX_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
3
+ export interface BwrapExecInput {
4
+ readonly bwrapPath: string;
5
+ readonly workspaceDir: string;
6
+ /** Host paths mounted over with an empty tmpfs. Caller filters to existing paths. */
7
+ readonly hidePaths: readonly string[];
8
+ readonly shareNetwork: boolean;
9
+ /** Final merged environment; with --clearenv the sandbox sees exactly these variables. */
10
+ readonly env: Readonly<Record<string, string>>;
11
+ /** Sandbox-visible working directory (already /workspace-anchored). */
12
+ readonly chdir: string;
13
+ readonly command: string;
14
+ }
15
+ export declare function buildBwrapExecArgs(input: BwrapExecInput): string[];
package/dist/args.js ADDED
@@ -0,0 +1,32 @@
1
+ import { WORKSPACE_ROOT } from "./paths.js";
2
+ /** PATH the sandbox sees; the host rootfs is visible read-only, so the standard dirs apply. */
3
+ export const DEFAULT_SANDBOX_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
4
+ export function buildBwrapExecArgs(input) {
5
+ const args = [
6
+ input.bwrapPath,
7
+ "--ro-bind",
8
+ "/",
9
+ "/",
10
+ "--dev",
11
+ "/dev",
12
+ "--proc",
13
+ "/proc",
14
+ "--tmpfs",
15
+ "/tmp",
16
+ ];
17
+ // Hide paths BEFORE re-binding the workspace: bind sources resolve against
18
+ // the host filesystem, so a later bind punches through an earlier tmpfs.
19
+ for (const path of input.hidePaths) {
20
+ args.push("--tmpfs", path);
21
+ }
22
+ args.push("--bind", input.workspaceDir, WORKSPACE_ROOT);
23
+ if (!input.shareNetwork) {
24
+ args.push("--unshare-net");
25
+ }
26
+ args.push("--unshare-pid", "--unshare-ipc", "--unshare-uts", "--die-with-parent", "--clearenv");
27
+ for (const [key, value] of Object.entries(input.env)) {
28
+ args.push("--setenv", key, value);
29
+ }
30
+ args.push("--chdir", input.chdir, "bash", "-lc", input.command);
31
+ return args;
32
+ }
@@ -0,0 +1,14 @@
1
+ import type { SandboxBackend } from "eve/sandbox";
2
+ import type { BwrapSandboxCreateOptions } from "./options.js";
3
+ import type { ProcessRunner } from "./process.js";
4
+ /**
5
+ * Stable backend name. Participates in eve's template/session cache-key
6
+ * derivation and persisted reconnect state — never change it.
7
+ */
8
+ export declare const BWRAP_BACKEND_NAME = "bwrap";
9
+ export interface CreateBwrapSandboxBackendInput {
10
+ readonly createOptions?: BwrapSandboxCreateOptions;
11
+ /** Injectable process launcher so backend logic is testable without bwrap. */
12
+ readonly runner?: ProcessRunner;
13
+ }
14
+ export declare function createBwrapSandboxBackend(input?: CreateBwrapSandboxBackendInput): SandboxBackend;
@@ -0,0 +1,130 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { cp, mkdir, rename, rm } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import { SandboxTemplateNotProvisionedError } from "eve/sandbox";
6
+ import { createBwrapOptionsHash, resolveBwrapSandboxOptions } from "./options.js";
7
+ import { resolveSessionPath, resolveTemplatePath, WORKSPACE_ROOT } from "./paths.js";
8
+ import { createNodeProcessRunner, describeMissingPrereqs, isBwrapAvailable } from "./process.js";
9
+ import { createBwrapSession } from "./session.js";
10
+ const EVE_MODEL_SKILL_ROOT = "$HOME/.agents/skills";
11
+ /**
12
+ * Stable backend name. Participates in eve's template/session cache-key
13
+ * derivation and persisted reconnect state — never change it.
14
+ */
15
+ export const BWRAP_BACKEND_NAME = "bwrap";
16
+ async function copyDirectoryAtomically(sourcePath, targetPath) {
17
+ const tmpPath = `${targetPath}.${randomUUID()}.tmp`;
18
+ await mkdir(dirname(targetPath), { recursive: true });
19
+ try {
20
+ await cp(sourcePath, tmpPath, { recursive: true });
21
+ await rename(tmpPath, targetPath);
22
+ }
23
+ catch (error) {
24
+ await rm(tmpPath, { force: true, recursive: true }).catch(() => { });
25
+ // A concurrent writer winning the rename race is success, not failure.
26
+ if (existsSync(targetPath))
27
+ return;
28
+ throw error;
29
+ }
30
+ }
31
+ export function createBwrapSandboxBackend(input = {}) {
32
+ const options = resolveBwrapSandboxOptions(input.createOptions);
33
+ const optionsHash = createBwrapOptionsHash(options);
34
+ const runner = input.runner ?? createNodeProcessRunner();
35
+ // Probe only when running against the real bwrap; injected runners skip it.
36
+ const shouldProbe = input.runner === undefined;
37
+ let probed = false;
38
+ function assertBwrapAvailable() {
39
+ if (!shouldProbe || probed)
40
+ return;
41
+ const missing = describeMissingPrereqs({
42
+ bwrapPresent: isBwrapAvailable(options.bwrapPath),
43
+ workspaceMountpointPresent: existsSync(WORKSPACE_ROOT),
44
+ bwrapPath: options.bwrapPath,
45
+ });
46
+ if (missing)
47
+ throw new Error(missing);
48
+ probed = true;
49
+ }
50
+ function openSession(id, workspaceDir, appRoot) {
51
+ return createBwrapSession({ id, workspaceDir, appRoot, runner, options });
52
+ }
53
+ function resolveSeedPath(seedPath) {
54
+ if (seedPath === EVE_MODEL_SKILL_ROOT || seedPath.startsWith(`${EVE_MODEL_SKILL_ROOT}/`)) {
55
+ return `${WORKSPACE_ROOT}/.agents/skills${seedPath.slice(EVE_MODEL_SKILL_ROOT.length)}`;
56
+ }
57
+ return seedPath;
58
+ }
59
+ async function writeSeedFiles(session, seedFiles) {
60
+ for (const seed of seedFiles) {
61
+ const seedPath = resolveSeedPath(seed.path);
62
+ if (typeof seed.content === "string") {
63
+ await session.writeTextFile({ path: seedPath, content: seed.content });
64
+ }
65
+ else {
66
+ await session.writeBinaryFile({ path: seedPath, content: seed.content });
67
+ }
68
+ }
69
+ }
70
+ return {
71
+ name: BWRAP_BACKEND_NAME,
72
+ async prewarm({ templateKey, bootstrap, seedFiles, log, runtimeContext }) {
73
+ assertBwrapAvailable();
74
+ const templatePath = resolveTemplatePath(runtimeContext.appRoot, templateKey, optionsHash, options.cacheDir);
75
+ if (existsSync(templatePath))
76
+ return { reused: true };
77
+ log?.(`bwrap: capturing template for ${templateKey}`);
78
+ const stagingPath = `${templatePath}.staging-${randomUUID()}`;
79
+ await mkdir(stagingPath, { recursive: true });
80
+ try {
81
+ const session = openSession(templateKey, stagingPath, runtimeContext.appRoot);
82
+ if (bootstrap)
83
+ await bootstrap({ use: async () => session });
84
+ await writeSeedFiles(session, seedFiles);
85
+ await rename(stagingPath, templatePath);
86
+ }
87
+ catch (error) {
88
+ await rm(stagingPath, { force: true, recursive: true }).catch(() => { });
89
+ // A concurrent prewarm winning the race is reuse, not failure.
90
+ if (existsSync(templatePath))
91
+ return { reused: true };
92
+ throw error;
93
+ }
94
+ return { reused: false };
95
+ },
96
+ async create({ templateKey, sessionKey, runtimeContext }) {
97
+ assertBwrapAvailable();
98
+ const sessionPath = resolveSessionPath(runtimeContext.appRoot, sessionKey, options.cacheDir);
99
+ if (!existsSync(sessionPath)) {
100
+ if (templateKey === null) {
101
+ await mkdir(sessionPath, { recursive: true });
102
+ }
103
+ else {
104
+ const templatePath = resolveTemplatePath(runtimeContext.appRoot, templateKey, optionsHash, options.cacheDir);
105
+ if (!existsSync(templatePath)) {
106
+ throw new SandboxTemplateNotProvisionedError({
107
+ backendName: BWRAP_BACKEND_NAME,
108
+ templateKey,
109
+ });
110
+ }
111
+ await copyDirectoryAtomically(templatePath, sessionPath);
112
+ }
113
+ }
114
+ const session = openSession(sessionKey, sessionPath, runtimeContext.appRoot);
115
+ return {
116
+ session,
117
+ useSessionFn: async () => session,
118
+ async captureState() {
119
+ return { backendName: BWRAP_BACKEND_NAME, metadata: {}, sessionKey };
120
+ },
121
+ // eve calls this when the server is shutting down: nothing may be left
122
+ // running afterwards. The workspace directory IS the durable state, so
123
+ // it stays on disk and the session reattaches on the next start.
124
+ async shutdown() {
125
+ await session.killAll();
126
+ },
127
+ };
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,20 @@
1
+ import type { SandboxBackend } from "eve/sandbox";
2
+ import type { BwrapSandboxCreateOptions } from "./options.js";
3
+ export { BWRAP_BACKEND_NAME, createBwrapSandboxBackend, type CreateBwrapSandboxBackendInput, } from "./backend.js";
4
+ export type { BwrapNetworkPolicy, BwrapSandboxCreateOptions } from "./options.js";
5
+ export { isBwrapAvailable } from "./process.js";
6
+ export type { ProcessRunner, SpawnedProcess } from "./process.js";
7
+ /**
8
+ * Creates the bubblewrap sandbox backend for `defineSandbox({ backend })`.
9
+ *
10
+ * ```ts
11
+ * // agent/sandbox.ts
12
+ * import { defineSandbox, defaultBackend } from "eve/sandbox";
13
+ * import { bwrap, isBwrapAvailable } from "@evelandhq/sandbox-bwrap";
14
+ *
15
+ * export default defineSandbox({
16
+ * backend: () => (isBwrapAvailable() ? bwrap() : defaultBackend()),
17
+ * });
18
+ * ```
19
+ */
20
+ export declare function bwrap(options?: BwrapSandboxCreateOptions): SandboxBackend;
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ import { createBwrapSandboxBackend } from "./backend.js";
2
+ export { BWRAP_BACKEND_NAME, createBwrapSandboxBackend, } from "./backend.js";
3
+ export { isBwrapAvailable } from "./process.js";
4
+ /**
5
+ * Creates the bubblewrap sandbox backend for `defineSandbox({ backend })`.
6
+ *
7
+ * ```ts
8
+ * // agent/sandbox.ts
9
+ * import { defineSandbox, defaultBackend } from "eve/sandbox";
10
+ * import { bwrap, isBwrapAvailable } from "@evelandhq/sandbox-bwrap";
11
+ *
12
+ * export default defineSandbox({
13
+ * backend: () => (isBwrapAvailable() ? bwrap() : defaultBackend()),
14
+ * });
15
+ * ```
16
+ */
17
+ export function bwrap(options) {
18
+ return createBwrapSandboxBackend({ createOptions: options });
19
+ }
@@ -0,0 +1,42 @@
1
+ /** Coarse egress control, matching what eve's Docker backend supports. */
2
+ export type BwrapNetworkPolicy = "allow-all" | "deny-all";
3
+ /** Options accepted by `bwrap(opts)`. */
4
+ export interface BwrapSandboxCreateOptions {
5
+ /** Environment variables set for every command the backend runs. */
6
+ readonly env?: Readonly<Record<string, string>>;
7
+ /** Initial network policy for sandboxed commands. Defaults to `"allow-all"`. */
8
+ readonly networkPolicy?: BwrapNetworkPolicy;
9
+ /** Extra host paths hidden from the sandbox (each mounted over with an empty tmpfs). */
10
+ readonly hidePaths?: readonly string[];
11
+ /** bwrap executable path. Defaults to `"bwrap"` resolved via PATH. */
12
+ readonly bwrapPath?: string;
13
+ /**
14
+ * Absolute directory holding templates and durable session workspaces.
15
+ * Defaults to `<appRoot>/.eve/sandbox-cache/bwrap`. Pin it outside the
16
+ * release directory so a redeploy does not discard durable session state
17
+ * (eve keys session sandboxes per durable session, not per deployment).
18
+ */
19
+ readonly cacheDir?: string;
20
+ /**
21
+ * Immutable release identity used to refresh workspace templates after a
22
+ * deploy. It deliberately affects templates only; durable session paths
23
+ * remain keyed solely by Eve's session key.
24
+ */
25
+ readonly templateRevision?: string;
26
+ }
27
+ /** Fully-defaulted options consumed by the backend implementation. */
28
+ export interface ResolvedBwrapSandboxOptions {
29
+ readonly env: Readonly<Record<string, string>>;
30
+ readonly networkPolicy: BwrapNetworkPolicy;
31
+ readonly hidePaths: readonly string[];
32
+ readonly bwrapPath: string;
33
+ readonly cacheDir: string | null;
34
+ readonly templateRevision: string | null;
35
+ }
36
+ export declare function resolveBwrapSandboxOptions(options?: BwrapSandboxCreateOptions): ResolvedBwrapSandboxOptions;
37
+ /**
38
+ * Hash of the resolved options. Participates in template path derivation so
39
+ * templates captured under different options never mix (parity with the
40
+ * Docker backend's options hash).
41
+ */
42
+ export declare function createBwrapOptionsHash(options: ResolvedBwrapSandboxOptions): string;
@@ -0,0 +1,27 @@
1
+ import { createHash } from "node:crypto";
2
+ export function resolveBwrapSandboxOptions(options = {}) {
3
+ return {
4
+ env: options.env ?? {},
5
+ networkPolicy: options.networkPolicy ?? "allow-all",
6
+ hidePaths: options.hidePaths ?? [],
7
+ bwrapPath: options.bwrapPath ?? "bwrap",
8
+ cacheDir: options.cacheDir ?? null,
9
+ templateRevision: options.templateRevision ?? null,
10
+ };
11
+ }
12
+ /**
13
+ * Hash of the resolved options. Participates in template path derivation so
14
+ * templates captured under different options never mix (parity with the
15
+ * Docker backend's options hash).
16
+ */
17
+ export function createBwrapOptionsHash(options) {
18
+ const canonical = JSON.stringify({
19
+ bwrapPath: options.bwrapPath,
20
+ cacheDir: options.cacheDir,
21
+ env: Object.fromEntries(Object.entries(options.env).sort(([a], [b]) => (a < b ? -1 : 1))),
22
+ hidePaths: [...options.hidePaths],
23
+ networkPolicy: options.networkPolicy,
24
+ templateRevision: options.templateRevision,
25
+ });
26
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
27
+ }
@@ -0,0 +1,31 @@
1
+ /** Sandbox-visible workspace root; parity with eve's built-in local backends. */
2
+ export declare const WORKSPACE_ROOT = "/workspace";
3
+ /**
4
+ * Templates and durable session workspaces. `cacheDir` pins the location
5
+ * outside the release directory; without it the cache follows eve's local
6
+ * convention under the app root.
7
+ */
8
+ export declare function resolveBwrapCacheRoot(appRoot: string, cacheDir?: string | null): string;
9
+ export declare function resolveTemplatePath(appRoot: string, templateKey: string, optionsHash: string, cacheDir?: string | null): string;
10
+ export declare function resolveSessionPath(appRoot: string, sessionKey: string, cacheDir?: string | null): string;
11
+ /** Anchors a sandbox-relative path to /workspace; absolute paths pass through. */
12
+ export declare function resolveWorkspacePath(path: string): string;
13
+ /**
14
+ * Translates a sandbox-visible path to the host path backing it: /workspace
15
+ * maps to the session directory, anything else is the same path on the host.
16
+ */
17
+ export declare function toHostPath(path: string, workspaceDir: string): string;
18
+ /** True when hostPath is workspaceDir or inside it after normalization. */
19
+ export declare function isWithinWorkspace(hostPath: string, workspaceDir: string): boolean;
20
+ /**
21
+ * Symlink-aware containment: resolves the deepest existing ancestor of
22
+ * hostPath (following symlinks) and re-checks that the real target stays
23
+ * inside the real workspace directory. Not-yet-existing trailing components
24
+ * cannot be symlinks, so they are appended lexically. Returns false when a
25
+ * symlink in the chain is dangling (a write through it would create the
26
+ * file at the symlink's target). Note: this closes the planted-symlink
27
+ * escape; a race between this check and the following fs operation remains
28
+ * theoretically possible (Node exposes no RESOLVE_BENEATH), which is an
29
+ * accepted residual risk documented in the README.
30
+ */
31
+ export declare function isWithinWorkspaceReal(hostPath: string, workspaceDir: string): boolean;
package/dist/paths.js ADDED
@@ -0,0 +1,93 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, realpathSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative } from "node:path";
4
+ /** Sandbox-visible workspace root; parity with eve's built-in local backends. */
5
+ export const WORKSPACE_ROOT = "/workspace";
6
+ /**
7
+ * Templates and durable session workspaces. `cacheDir` pins the location
8
+ * outside the release directory; without it the cache follows eve's local
9
+ * convention under the app root.
10
+ */
11
+ export function resolveBwrapCacheRoot(appRoot, cacheDir) {
12
+ return cacheDir ?? join(appRoot, ".eve", "sandbox-cache", "bwrap");
13
+ }
14
+ function keyDigest(value) {
15
+ return createHash("sha256").update(value).digest("hex").slice(0, 32);
16
+ }
17
+ export function resolveTemplatePath(appRoot, templateKey, optionsHash, cacheDir) {
18
+ return join(resolveBwrapCacheRoot(appRoot, cacheDir), "templates", `${keyDigest(templateKey)}-${optionsHash}`);
19
+ }
20
+ export function resolveSessionPath(appRoot, sessionKey, cacheDir) {
21
+ return join(resolveBwrapCacheRoot(appRoot, cacheDir), "sessions", keyDigest(sessionKey));
22
+ }
23
+ /** Anchors a sandbox-relative path to /workspace; absolute paths pass through. */
24
+ export function resolveWorkspacePath(path) {
25
+ return path.startsWith("/") ? path : `${WORKSPACE_ROOT}/${path}`;
26
+ }
27
+ /**
28
+ * Translates a sandbox-visible path to the host path backing it: /workspace
29
+ * maps to the session directory, anything else is the same path on the host.
30
+ */
31
+ export function toHostPath(path, workspaceDir) {
32
+ const resolved = resolveWorkspacePath(path);
33
+ if (resolved === WORKSPACE_ROOT)
34
+ return workspaceDir;
35
+ if (resolved.startsWith(`${WORKSPACE_ROOT}/`)) {
36
+ return join(workspaceDir, resolved.slice(WORKSPACE_ROOT.length + 1));
37
+ }
38
+ return resolved;
39
+ }
40
+ /** True when hostPath is workspaceDir or inside it after normalization. */
41
+ export function isWithinWorkspace(hostPath, workspaceDir) {
42
+ const rel = relative(workspaceDir, hostPath);
43
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
44
+ }
45
+ function lexists(path) {
46
+ try {
47
+ lstatSync(path);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /**
55
+ * Symlink-aware containment: resolves the deepest existing ancestor of
56
+ * hostPath (following symlinks) and re-checks that the real target stays
57
+ * inside the real workspace directory. Not-yet-existing trailing components
58
+ * cannot be symlinks, so they are appended lexically. Returns false when a
59
+ * symlink in the chain is dangling (a write through it would create the
60
+ * file at the symlink's target). Note: this closes the planted-symlink
61
+ * escape; a race between this check and the following fs operation remains
62
+ * theoretically possible (Node exposes no RESOLVE_BENEATH), which is an
63
+ * accepted residual risk documented in the README.
64
+ */
65
+ export function isWithinWorkspaceReal(hostPath, workspaceDir) {
66
+ if (!isWithinWorkspace(hostPath, workspaceDir))
67
+ return false;
68
+ let realWorkspace;
69
+ try {
70
+ realWorkspace = realpathSync(workspaceDir);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ let probe = hostPath;
76
+ const missing = [];
77
+ while (!lexists(probe)) {
78
+ const parent = dirname(probe);
79
+ if (parent === probe)
80
+ return false;
81
+ missing.push(basename(probe));
82
+ probe = parent;
83
+ }
84
+ let resolvedProbe;
85
+ try {
86
+ resolvedProbe = realpathSync(probe);
87
+ }
88
+ catch {
89
+ return false; // dangling symlink in the chain
90
+ }
91
+ const finalPath = missing.length === 0 ? resolvedProbe : join(resolvedProbe, ...missing.reverse());
92
+ return isWithinWorkspace(finalPath, realWorkspace);
93
+ }
@@ -0,0 +1,24 @@
1
+ /** Mirrors the AI SDK SandboxProcess surface so sessions can return it directly. */
2
+ export interface SpawnedProcess {
3
+ readonly pid?: number;
4
+ readonly stdout: ReadableStream<Uint8Array>;
5
+ readonly stderr: ReadableStream<Uint8Array>;
6
+ wait(): Promise<{
7
+ exitCode: number;
8
+ }>;
9
+ kill(): Promise<void>;
10
+ }
11
+ /** Injectable process launcher so backend logic is unit-testable without bwrap. */
12
+ export interface ProcessRunner {
13
+ spawn(argv: readonly string[], options?: {
14
+ readonly abortSignal?: AbortSignal;
15
+ }): SpawnedProcess;
16
+ }
17
+ export declare function isBwrapAvailable(bwrapPath?: string): boolean;
18
+ /** Explains missing host prerequisites, or null when the host is ready. */
19
+ export declare function describeMissingPrereqs(probes: {
20
+ readonly bwrapPresent: boolean;
21
+ readonly workspaceMountpointPresent: boolean;
22
+ readonly bwrapPath: string;
23
+ }): string | null;
24
+ export declare function createNodeProcessRunner(): ProcessRunner;
@@ -0,0 +1,84 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { Readable } from "node:stream";
3
+ import { WORKSPACE_ROOT } from "./paths.js";
4
+ const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGKILL: 137, SIGTERM: 143 };
5
+ export function isBwrapAvailable(bwrapPath = "bwrap") {
6
+ return spawnSync(bwrapPath, ["--version"], { stdio: "ignore" }).status === 0;
7
+ }
8
+ /** Explains missing host prerequisites, or null when the host is ready. */
9
+ export function describeMissingPrereqs(probes) {
10
+ const problems = [];
11
+ if (!probes.bwrapPresent) {
12
+ problems.push(`bubblewrap is not available (tried "${probes.bwrapPath} --version"). ` +
13
+ "Install it with your distro package manager (Ubuntu/Debian: apt-get install bubblewrap), " +
14
+ "or select a different backend outside Linux, e.g. " +
15
+ "backend: () => (isBwrapAvailable() ? bwrap() : defaultBackend()).");
16
+ }
17
+ if (!probes.workspaceMountpointPresent) {
18
+ problems.push(`${WORKSPACE_ROOT} does not exist on the host. bwrap binds each session directory onto ` +
19
+ `${WORKSPACE_ROOT} inside the sandbox, but it cannot create that mountpoint itself because the ` +
20
+ `host root is bind-mounted read-only first. Create it once: sudo install -d -m 0755 ${WORKSPACE_ROOT}`);
21
+ }
22
+ return problems.length === 0 ? null : problems.join(" ");
23
+ }
24
+ export function createNodeProcessRunner() {
25
+ return {
26
+ spawn(argv, options) {
27
+ const [command, ...rest] = argv;
28
+ if (!command) {
29
+ throw new Error("ProcessRunner.spawn requires a non-empty argv");
30
+ }
31
+ // detached: the child leads its own process group, so kill(-pid) reaps
32
+ // the entire sandboxed tree (bwrap and everything inside it).
33
+ const child = spawn(command, rest, { detached: true, stdio: ["ignore", "pipe", "pipe"] });
34
+ const exit = new Promise((resolvePromise, reject) => {
35
+ child.once("error", reject);
36
+ child.once("exit", (code, signal) => {
37
+ resolvePromise({ exitCode: code ?? (signal ? (SIGNAL_EXIT_CODES[signal] ?? 1) : 1) });
38
+ });
39
+ });
40
+ exit.catch(() => { });
41
+ const killTree = () => {
42
+ if (child.pid !== undefined) {
43
+ try {
44
+ process.kill(-child.pid, "SIGKILL");
45
+ return;
46
+ }
47
+ catch {
48
+ // fall through: group already gone or not yet set up
49
+ }
50
+ }
51
+ child.kill("SIGKILL");
52
+ };
53
+ let aborted = false;
54
+ let abortReason;
55
+ const signal = options?.abortSignal;
56
+ if (signal) {
57
+ const onAbort = () => {
58
+ aborted = true;
59
+ abortReason = signal.reason;
60
+ killTree();
61
+ };
62
+ if (signal.aborted)
63
+ onAbort();
64
+ else
65
+ signal.addEventListener("abort", onAbort, { once: true });
66
+ }
67
+ return {
68
+ pid: child.pid,
69
+ stdout: Readable.toWeb(child.stdout),
70
+ stderr: Readable.toWeb(child.stderr),
71
+ async wait() {
72
+ const result = await exit;
73
+ if (aborted)
74
+ throw abortReason;
75
+ return result;
76
+ },
77
+ async kill() {
78
+ killTree();
79
+ await exit.catch(() => { });
80
+ },
81
+ };
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,20 @@
1
+ import type { SandboxSession } from "eve/sandbox";
2
+ import type { ResolvedBwrapSandboxOptions } from "./options.js";
3
+ import type { ProcessRunner } from "./process.js";
4
+ export interface CreateBwrapSessionInput {
5
+ readonly id: string;
6
+ readonly workspaceDir: string;
7
+ readonly appRoot: string;
8
+ readonly runner: ProcessRunner;
9
+ readonly options: ResolvedBwrapSandboxOptions;
10
+ }
11
+ /**
12
+ * A sandbox session plus the lifecycle hook the backend handle needs.
13
+ * eve's `shutdown()` contract requires that nothing is left running, so the
14
+ * session tracks the processes it spawned and can terminate them on demand.
15
+ */
16
+ export type BwrapSession = SandboxSession & {
17
+ /** Kills every process this session spawned that has not yet exited. Idempotent. */
18
+ killAll(): Promise<void>;
19
+ };
20
+ export declare function createBwrapSession(input: CreateBwrapSessionInput): BwrapSession;
@@ -0,0 +1,183 @@
1
+ import { createReadStream, existsSync } from "node:fs";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import { pipeline } from "node:stream/promises";
6
+ import { createWriteStream } from "node:fs";
7
+ import { buildBwrapExecArgs, DEFAULT_SANDBOX_PATH } from "./args.js";
8
+ import { isWithinWorkspaceReal, resolveBwrapCacheRoot, resolveWorkspacePath, toHostPath, WORKSPACE_ROOT, } from "./paths.js";
9
+ function isMissingFileError(error) {
10
+ return (typeof error === "object" &&
11
+ error !== null &&
12
+ error.code === "ENOENT");
13
+ }
14
+ async function collectStream(stream) {
15
+ const chunks = [];
16
+ const reader = stream.getReader();
17
+ for (;;) {
18
+ const { done, value } = await reader.read();
19
+ if (done)
20
+ break;
21
+ chunks.push(value);
22
+ }
23
+ return Buffer.concat(chunks).toString("utf8");
24
+ }
25
+ function decodeText(bytes, encoding) {
26
+ if (encoding === "utf-8" || encoding === "utf8") {
27
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
28
+ }
29
+ return bytes.toString(encoding);
30
+ }
31
+ function sliceLines(text, startLine, endLine) {
32
+ if (startLine === undefined && endLine === undefined)
33
+ return text;
34
+ const lines = text.split("\n");
35
+ return lines.slice((startLine ?? 1) - 1, endLine ?? lines.length).join("\n");
36
+ }
37
+ export function createBwrapSession(input) {
38
+ const { id, workspaceDir, appRoot, runner, options } = input;
39
+ let networkPolicy = options.networkPolicy;
40
+ const host = (path) => toHostPath(path, workspaceDir);
41
+ function writableHostPath(path, operation) {
42
+ const hostPath = host(path);
43
+ if (!isWithinWorkspaceReal(hostPath, workspaceDir)) {
44
+ throw new Error(`bwrap sandbox: refusing to ${operation} outside ${WORKSPACE_ROOT}: ${path}`);
45
+ }
46
+ return hostPath;
47
+ }
48
+ const live = new Set();
49
+ // Wrap wait()/kill() rather than eagerly calling proc.wait() ourselves to
50
+ // watch for exit: a fire-and-forget wait() started at spawn time races
51
+ // ahead of the caller (its resolution is not tied to when anyone actually
52
+ // observes the process), so a process can be silently untracked before
53
+ // killAll() ever sees it. Tying removal to the caller's own wait()/kill()
54
+ // call keeps "still tracked" in sync with "still owned by the caller":
55
+ // run() untracks itself the moment its internal wait() settles, and a
56
+ // bare spawn() stays tracked (and killable) until its holder collects it.
57
+ function track(proc) {
58
+ const wrapped = {
59
+ pid: proc.pid,
60
+ stdout: proc.stdout,
61
+ stderr: proc.stderr,
62
+ async wait() {
63
+ try {
64
+ return await proc.wait();
65
+ }
66
+ finally {
67
+ live.delete(wrapped);
68
+ }
69
+ },
70
+ async kill() {
71
+ try {
72
+ await proc.kill();
73
+ }
74
+ finally {
75
+ live.delete(wrapped);
76
+ }
77
+ },
78
+ };
79
+ live.add(wrapped);
80
+ return wrapped;
81
+ }
82
+ async function spawnProcess(spawnOptions) {
83
+ const env = {
84
+ PATH: DEFAULT_SANDBOX_PATH,
85
+ HOME: WORKSPACE_ROOT,
86
+ LANG: "C.UTF-8",
87
+ ...options.env,
88
+ ...spawnOptions.env,
89
+ };
90
+ const hidePaths = [
91
+ resolveBwrapCacheRoot(appRoot, options.cacheDir),
92
+ ...options.hidePaths,
93
+ ].filter((path) => existsSync(path));
94
+ const argv = buildBwrapExecArgs({
95
+ bwrapPath: options.bwrapPath,
96
+ workspaceDir,
97
+ hidePaths,
98
+ shareNetwork: networkPolicy === "allow-all",
99
+ env,
100
+ chdir: resolveWorkspacePath(spawnOptions.workingDirectory ?? WORKSPACE_ROOT),
101
+ command: spawnOptions.command,
102
+ });
103
+ return track(runner.spawn(argv, { abortSignal: spawnOptions.abortSignal }));
104
+ }
105
+ return {
106
+ id,
107
+ resolvePath: resolveWorkspacePath,
108
+ async killAll() {
109
+ const pending = [...live];
110
+ live.clear();
111
+ await Promise.all(pending.map((proc) => proc.kill().catch(() => undefined)));
112
+ },
113
+ async spawn(spawnOptions) {
114
+ return await spawnProcess(spawnOptions);
115
+ },
116
+ async run(runOptions) {
117
+ const proc = await spawnProcess(runOptions);
118
+ const [stdout, stderr] = await Promise.all([
119
+ collectStream(proc.stdout),
120
+ collectStream(proc.stderr),
121
+ ]);
122
+ const { exitCode } = await proc.wait();
123
+ return { exitCode, stdout, stderr };
124
+ },
125
+ async setNetworkPolicy(policy) {
126
+ if (policy !== "allow-all" && policy !== "deny-all") {
127
+ throw new Error('bwrap backend supports only the "allow-all" and "deny-all" network policies');
128
+ }
129
+ networkPolicy = policy;
130
+ },
131
+ async readFile({ path }) {
132
+ const hostPath = host(path);
133
+ if (!existsSync(hostPath))
134
+ return null;
135
+ return Readable.toWeb(createReadStream(hostPath));
136
+ },
137
+ async readBinaryFile({ path }) {
138
+ try {
139
+ const bytes = await readFile(host(path));
140
+ return new Uint8Array(bytes);
141
+ }
142
+ catch (error) {
143
+ if (isMissingFileError(error))
144
+ return null;
145
+ throw error;
146
+ }
147
+ },
148
+ async readTextFile({ path, encoding, startLine, endLine }) {
149
+ try {
150
+ const bytes = await readFile(host(path));
151
+ return sliceLines(decodeText(bytes, encoding ?? "utf-8"), startLine, endLine);
152
+ }
153
+ catch (error) {
154
+ if (isMissingFileError(error))
155
+ return null;
156
+ throw error;
157
+ }
158
+ },
159
+ async writeFile({ path, content }) {
160
+ const hostPath = writableHostPath(path, "write");
161
+ await mkdir(dirname(hostPath), { recursive: true });
162
+ await pipeline(Readable.fromWeb(content), createWriteStream(hostPath));
163
+ },
164
+ async writeBinaryFile({ path, content }) {
165
+ const hostPath = writableHostPath(path, "write");
166
+ await mkdir(dirname(hostPath), { recursive: true });
167
+ await writeFile(hostPath, content);
168
+ },
169
+ async writeTextFile({ path, content, encoding }) {
170
+ const hostPath = writableHostPath(path, "write");
171
+ await mkdir(dirname(hostPath), { recursive: true });
172
+ const enc = encoding === undefined || encoding === "utf-8" ? "utf8" : encoding;
173
+ await writeFile(hostPath, Buffer.from(content, enc));
174
+ },
175
+ async removePath({ path, force, recursive }) {
176
+ const hostPath = writableHostPath(path, "remove");
177
+ if (force !== true && !existsSync(hostPath)) {
178
+ throw new Error(`bwrap sandbox: path does not exist: ${path}`);
179
+ }
180
+ await rm(hostPath, { force: force === true, recursive: recursive === true });
181
+ },
182
+ };
183
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@evelandhq/sandbox-bwrap",
3
+ "version": "0.1.0",
4
+ "description": "bubblewrap SandboxBackend for eve agents — real exec sandboxing without Docker or KVM",
5
+ "keywords": [
6
+ "agent",
7
+ "bubblewrap",
8
+ "bwrap",
9
+ "eve",
10
+ "sandbox"
11
+ ],
12
+ "license": "Apache-2.0",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/evelandhq/sandbox-bwrap.git"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "type": "module",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js",
27
+ "default": "./dist/index.js"
28
+ }
29
+ },
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.build.json",
32
+ "typecheck": "tsc -p tsconfig.json --noEmit",
33
+ "test": "vitest run",
34
+ "fmt": "oxfmt",
35
+ "fmt:check": "oxfmt --check",
36
+ "lint": "oxlint",
37
+ "lint:fix": "oxlint --fix",
38
+ "prepack": "npm run build"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^26.0.1",
42
+ "ai": "^7.0.44",
43
+ "eve": "0.30.8",
44
+ "eve-floor": "npm:eve@0.27.13",
45
+ "oxfmt": "0.58.0",
46
+ "oxlint": "1.73.0",
47
+ "tsx": "^4.22.4",
48
+ "typescript": "^6.0.3",
49
+ "vitest": "^4.1.9"
50
+ },
51
+ "peerDependencies": {
52
+ "eve": ">=0.27.0 <1.0.0"
53
+ },
54
+ "engines": {
55
+ "node": ">=24.0.0"
56
+ },
57
+ "packageManager": "pnpm@11.7.0"
58
+ }