@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 +202 -0
- package/README.md +232 -0
- package/dist/args.d.ts +15 -0
- package/dist/args.js +32 -0
- package/dist/backend.d.ts +14 -0
- package/dist/backend.js +130 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +19 -0
- package/dist/options.d.ts +42 -0
- package/dist/options.js +27 -0
- package/dist/paths.d.ts +31 -0
- package/dist/paths.js +93 -0
- package/dist/process.d.ts +24 -0
- package/dist/process.js +84 -0
- package/dist/session.d.ts +20 -0
- package/dist/session.js +183 -0
- package/package.json +58 -0
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;
|
package/dist/backend.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/options.js
ADDED
|
@@ -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
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -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;
|
package/dist/process.js
ADDED
|
@@ -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;
|
package/dist/session.js
ADDED
|
@@ -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
|
+
}
|