buddelkiste 0.1.0__tar.gz
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.
- buddelkiste-0.1.0/.gitignore +16 -0
- buddelkiste-0.1.0/.python-version +1 -0
- buddelkiste-0.1.0/PKG-INFO +302 -0
- buddelkiste-0.1.0/README.md +293 -0
- buddelkiste-0.1.0/justfile +32 -0
- buddelkiste-0.1.0/pyproject.toml +82 -0
- buddelkiste-0.1.0/scripts/release +480 -0
- buddelkiste-0.1.0/src/buddelkiste/__init__.py +3 -0
- buddelkiste-0.1.0/src/buddelkiste/__main__.py +4 -0
- buddelkiste-0.1.0/src/buddelkiste/binds.py +174 -0
- buddelkiste-0.1.0/src/buddelkiste/cli.py +721 -0
- buddelkiste-0.1.0/src/buddelkiste/dns_proxy.py +286 -0
- buddelkiste-0.1.0/src/buddelkiste/features.py +841 -0
- buddelkiste-0.1.0/src/buddelkiste/network.py +687 -0
- buddelkiste-0.1.0/src/buddelkiste/network_inner.py +46 -0
- buddelkiste-0.1.0/src/buddelkiste/shims.py +277 -0
- buddelkiste-0.1.0/tests/__init__.py +0 -0
- buddelkiste-0.1.0/tests/conftest.py +89 -0
- buddelkiste-0.1.0/tests/integ/__init__.py +1 -0
- buddelkiste-0.1.0/tests/integ/conftest.py +14 -0
- buddelkiste-0.1.0/tests/integ/test_bwrap_filesystem.py +256 -0
- buddelkiste-0.1.0/tests/integ/test_bwrap_isolation.py +103 -0
- buddelkiste-0.1.0/tests/integ/test_bwrap_modes.py +36 -0
- buddelkiste-0.1.0/tests/integ/test_nested_nft.py +99 -0
- buddelkiste-0.1.0/tests/integ/test_nft_and_dns.py +291 -0
- buddelkiste-0.1.0/tests/integ_net/__init__.py +1 -0
- buddelkiste-0.1.0/tests/integ_net/conftest.py +34 -0
- buddelkiste-0.1.0/tests/integ_net/test_filter_mode.py +73 -0
- buddelkiste-0.1.0/tests/integ_net/test_filter_policy.py +89 -0
- buddelkiste-0.1.0/tests/probes.py +102 -0
- buddelkiste-0.1.0/tests/test_binds.py +348 -0
- buddelkiste-0.1.0/tests/test_cli.py +269 -0
- buddelkiste-0.1.0/tests/test_config.py +281 -0
- buddelkiste-0.1.0/tests/test_dns_proxy.py +113 -0
- buddelkiste-0.1.0/tests/test_dns_proxy_extra.py +315 -0
- buddelkiste-0.1.0/tests/test_features.py +443 -0
- buddelkiste-0.1.0/tests/test_launch.py +94 -0
- buddelkiste-0.1.0/tests/test_network.py +315 -0
- buddelkiste-0.1.0/tests/test_network_extra.py +346 -0
- buddelkiste-0.1.0/tests/test_network_inner.py +117 -0
- buddelkiste-0.1.0/tests/test_shims.py +281 -0
- buddelkiste-0.1.0/uv.lock +337 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: buddelkiste
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run a command inside a bubblewrap sandbox
|
|
5
|
+
Author-email: Michael van Bracht <michael@wontfix.org>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: click>=8.1
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# buddelkiste
|
|
11
|
+
|
|
12
|
+
Run a command in a rootless sandbox (`bk`): filesystem and process isolation
|
|
13
|
+
via [bubblewrap](https://github.com/containers/bubblewrap), plus optional
|
|
14
|
+
network controls — share the host stack, cut connectivity entirely, or filter
|
|
15
|
+
egress with pasta/slirp4netns and in-namespace nftables (IP/CIDR and hostname
|
|
16
|
+
rules, no root required).
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Run a command in the sandbox
|
|
22
|
+
bk run /path/to/executable [args...]
|
|
23
|
+
|
|
24
|
+
# Inspect the sandbox (login shell)
|
|
25
|
+
bk run
|
|
26
|
+
|
|
27
|
+
# Catalog helpers
|
|
28
|
+
bk list-features
|
|
29
|
+
bk list-net-presets
|
|
30
|
+
|
|
31
|
+
# PATH shims for configured [executables.*] (in ~/.local/bin)
|
|
32
|
+
bk shims install
|
|
33
|
+
bk shims check
|
|
34
|
+
|
|
35
|
+
# Topic features (all on by default)
|
|
36
|
+
bk run --no-feature gui --no-feature google cursor-agent
|
|
37
|
+
bk run --feature python --feature ssh python myscript.py
|
|
38
|
+
|
|
39
|
+
# Network modes
|
|
40
|
+
bk run --network host … # share host network (default)
|
|
41
|
+
bk run --network none … # no connectivity
|
|
42
|
+
bk run --network filter \
|
|
43
|
+
--net-policy deny \
|
|
44
|
+
--net-allow 1.1.1.1/32 \
|
|
45
|
+
--net-allow api.github.com \
|
|
46
|
+
--net-allow '*.pypi.org' \
|
|
47
|
+
--net-deny-preset metadata \
|
|
48
|
+
--net-deny-preset private …
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`bk run` wrapper options: `--debug`, `--feature` / `--no-feature`,
|
|
52
|
+
`--network`, `--net-policy`, `--net-allow`, `--net-deny`, `--net-deny-preset`.
|
|
53
|
+
Everything else is the sandboxed command.
|
|
54
|
+
|
|
55
|
+
## Features
|
|
56
|
+
|
|
57
|
+
Optional permission sets are grouped by topic. Built-ins default to **on**.
|
|
58
|
+
Toggle with CLI flags or config; see `bk list-features` for the live catalog
|
|
59
|
+
(including each feature's module path).
|
|
60
|
+
|
|
61
|
+
| Feature | What it grants |
|
|
62
|
+
|---------|----------------|
|
|
63
|
+
| `asdf` | `~/.asdf`, `~/.tool-versions`, `ASDF_DIR` |
|
|
64
|
+
| `cursor` | Cursor IDE/CLI install and state dirs |
|
|
65
|
+
| `dbus` | session/system bus sockets, `DBUS_SESSION_BUS_ADDRESS` |
|
|
66
|
+
| `docker` | `~/.docker`, Docker socket / `DOCKER_HOST` |
|
|
67
|
+
| `git` | `~/.gitconfig`, `~/.config/git` |
|
|
68
|
+
| `google` | `/opt/google` |
|
|
69
|
+
| `gui` | display, GPU, audio, fonts, related env |
|
|
70
|
+
| `java` | OpenJDK `/etc/java-*-openjdk` configs |
|
|
71
|
+
| `locale` | `LANG`, `LC_NUMERIC`, `LC_TIME` |
|
|
72
|
+
| `node` | npm/nvm/bun paths, `BUN_INSTALL` |
|
|
73
|
+
| `nvim` | `~/nvim` |
|
|
74
|
+
| `python` | pip/virtualenv paths and env |
|
|
75
|
+
| `ssh` | `~/.ssh/config` plus a dedicated agent with `~/.ssh/sandbox_*` keys |
|
|
76
|
+
| `term` | `TERM`, `TERMINFO`, `COLORTERM`, `TERM_PROGRAM`, `EDITOR` |
|
|
77
|
+
| `user` | `~/.local` (ro), `~/.cache` (rw) |
|
|
78
|
+
| `xdg-open` | host `xdg-open` via flatpak-xdg-utils |
|
|
79
|
+
|
|
80
|
+
### CLI
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
bk list-features
|
|
84
|
+
|
|
85
|
+
# Tighten a desktop-ish run
|
|
86
|
+
bk run --no-feature gui --no-feature dbus --no-feature xdg-open curl https://example.com
|
|
87
|
+
|
|
88
|
+
# Minimal toolchains for a script
|
|
89
|
+
bk run --no-feature cursor --no-feature google --feature python --feature git python app.py
|
|
90
|
+
|
|
91
|
+
# Allowlist-style: disable broadly in config, then enable what you need
|
|
92
|
+
bk run --feature ssh --feature git git fetch
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Precedence (later wins for CLI flags): feature defaults → global config
|
|
96
|
+
`features` → per-executable `executables.<name>.features` →
|
|
97
|
+
`--feature` / `--no-feature`.
|
|
98
|
+
|
|
99
|
+
### Config
|
|
100
|
+
|
|
101
|
+
Configuration is read from `~/.config/buddelkiste/config.toml`, then merged with
|
|
102
|
+
`.buddelkiste.toml` from the current directory or an ancestor (project values
|
|
103
|
+
win). The project file is masked inside the sandbox (empty file bound over the
|
|
104
|
+
path) so the sandboxed command cannot read it.
|
|
105
|
+
|
|
106
|
+
```toml
|
|
107
|
+
# Global allowlist (exactly these features), or use a table of overrides:
|
|
108
|
+
# features = { gui = false, google = false }
|
|
109
|
+
features = ["git", "ssh", "python", "rust", "term", "locale", "user"]
|
|
110
|
+
|
|
111
|
+
[feature.rust]
|
|
112
|
+
description = "Rust toolchain directories"
|
|
113
|
+
default = false
|
|
114
|
+
env = ["CARGO_HOME", "RUSTUP_HOME"]
|
|
115
|
+
|
|
116
|
+
[[feature.rust.binds]]
|
|
117
|
+
source = "$CARGO_HOME"
|
|
118
|
+
mode = "rw"
|
|
119
|
+
|
|
120
|
+
[[feature.rust.binds]]
|
|
121
|
+
source = "${HOME}/.rustup"
|
|
122
|
+
mode = "ro"
|
|
123
|
+
|
|
124
|
+
[[feature.rust.binds]]
|
|
125
|
+
source = "${HOME}/.cargo/registry"
|
|
126
|
+
mode = "overlay" # persistent upper under $XDG_CACHE_HOME/buddelkiste/overlays/<hash>
|
|
127
|
+
|
|
128
|
+
[executables.cursor-agent]
|
|
129
|
+
features = ["cursor", "git", "ssh", "gui", "dbus", "xdg-open", "term"]
|
|
130
|
+
args = ["--force"]
|
|
131
|
+
|
|
132
|
+
[executables.cursor-agent.network]
|
|
133
|
+
mode = "filter"
|
|
134
|
+
policy = "deny"
|
|
135
|
+
allow = ["1.1.1.1/32", "api.github.com"]
|
|
136
|
+
|
|
137
|
+
[executables.python]
|
|
138
|
+
features = { python = true, git = true, gui = false }
|
|
139
|
+
shim = false
|
|
140
|
+
|
|
141
|
+
# shims = false # disable PATH shims globally (per-entry shim = true still wins)
|
|
142
|
+
|
|
143
|
+
[[binds]]
|
|
144
|
+
source = "/path/to/directory"
|
|
145
|
+
mode = "ro" # or "rw", "tmp-overlay", "overlay", "overlay:$XDG_CACHE_HOME/bk-upper"
|
|
146
|
+
|
|
147
|
+
[[binds]]
|
|
148
|
+
source = "${HOME}/scratch"
|
|
149
|
+
target = "/mnt/scratch"
|
|
150
|
+
mode = "tmp-overlay" # writable in the sandbox; host tree unchanged
|
|
151
|
+
|
|
152
|
+
[[envvars]]
|
|
153
|
+
name = "MYENVVAR"
|
|
154
|
+
value = "example value" # omit value= to take it from the process environment
|
|
155
|
+
|
|
156
|
+
[network]
|
|
157
|
+
mode = "filter"
|
|
158
|
+
policy = "deny"
|
|
159
|
+
allow = ["1.1.1.1/32", "api.github.com", "*.pypi.org"]
|
|
160
|
+
deny = ["203.0.113.0/24"]
|
|
161
|
+
deny_presets = ["metadata", "linklocal", "corp"]
|
|
162
|
+
|
|
163
|
+
[network.presets]
|
|
164
|
+
corp = ["10.50.0.0/16", "*.internal.example.com"]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Bind `mode` values:
|
|
168
|
+
|
|
169
|
+
| Mode | Meaning |
|
|
170
|
+
|------|---------|
|
|
171
|
+
| `ro` | read-only bind (default) |
|
|
172
|
+
| `rw` | read-write bind |
|
|
173
|
+
| `tmp-overlay` | overlayfs; writes are ephemeral |
|
|
174
|
+
| `overlay` | overlayfs; upper under `$XDG_CACHE_HOME/buddelkiste/overlays/<sha256(source)>` |
|
|
175
|
+
| `overlay:<path>` | overlayfs with an explicit upper path (`$VAR` / `${VAR}` / `~` ok) |
|
|
176
|
+
|
|
177
|
+
Paths in `source`, `target`, and `overlay:<path>` may use `$VAR` / `${VAR}`.
|
|
178
|
+
|
|
179
|
+
The current working directory must be visible inside the sandbox. If it is not
|
|
180
|
+
covered by any bind, `bk` asks whether to whitelist it for this run or
|
|
181
|
+
permanently (appends a `[[binds]]` entry). Non-interactively it errors instead.
|
|
182
|
+
|
|
183
|
+
### PATH shims
|
|
184
|
+
|
|
185
|
+
For each `[executables.<name>]` entry with shimming enabled, `bk shims install`
|
|
186
|
+
writes a small `sh` wrapper into `$XDG_BIN_HOME` (default `~/.local/bin`). The
|
|
187
|
+
shim resolves the real binary (skipping its own directory) and runs
|
|
188
|
+
`bk run <real> "$@"`. Shims include a `# buddelkiste-shim:` marker so later
|
|
189
|
+
installs can refresh our own files without overwriting unrelated binaries.
|
|
190
|
+
|
|
191
|
+
Toggle with a global `shims` boolean (default `true`) and optional per-entry
|
|
192
|
+
`shim = true/false` overrides. Disabled entries remove a managed shim on the
|
|
193
|
+
next `install`.
|
|
194
|
+
|
|
195
|
+
```toml
|
|
196
|
+
shims = true
|
|
197
|
+
|
|
198
|
+
[executables.cursor-agent]
|
|
199
|
+
features = ["cursor"]
|
|
200
|
+
|
|
201
|
+
[executables.python]
|
|
202
|
+
shim = false
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
bk shims install # create/update/remove shims per config
|
|
207
|
+
bk shims check # warn if an original binary appears earlier on PATH
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Ensure `~/.local/bin` is early on your `PATH`. `bk shims check` exits non-zero
|
|
211
|
+
when something would bypass the sandbox.
|
|
212
|
+
|
|
213
|
+
### Python features (entry points)
|
|
214
|
+
|
|
215
|
+
Built-ins and third-party packages register features under the
|
|
216
|
+
`buddelkiste.features` entry-point group. The entry point value must be a
|
|
217
|
+
`Feature` instance, or a zero-argument callable that returns one. When loaded,
|
|
218
|
+
`name` and `origin` are set from the entry-point name and value
|
|
219
|
+
(e.g. `rust` / `mypkg.features:RUST`).
|
|
220
|
+
|
|
221
|
+
`Feature` fields:
|
|
222
|
+
|
|
223
|
+
| Field | Type | Default | Meaning |
|
|
224
|
+
|-------|------|---------|---------|
|
|
225
|
+
| `name` | `str` | *(required)* | Feature id used in CLI/config (`--feature`, `features = [...]`). Overwritten by the entry-point name at load time. |
|
|
226
|
+
| `description` | `str` | *(required)* | One-line summary shown by `bk list-features`. |
|
|
227
|
+
| `default` | `bool` | `True` | Whether the feature is enabled before config/CLI overrides. |
|
|
228
|
+
| `env_vars` | `tuple[str, ...]` | `()` | Host env var names to forward into the sandbox when the feature is on. |
|
|
229
|
+
| `binds` | `Callable[[], list]` | `lambda: []` | Zero-arg callable returning bind objects (`ROBindConfig`, `RWBindConfig`, `DevBindConfig`, overlay configs, `Tmpfs`, or raw bwrap arg tuples). Called each run. |
|
|
230
|
+
| `setup` | `Callable[[], AbstractContextManager[Sequence[str]]] \| None` | `None` | Optional factory returning a context manager. Entered while the sandbox runs; its yielded sequence is appended as extra bwrap args (binds, `--setenv`, …). Use for sockets/agents that need lifecycle. |
|
|
231
|
+
| `origin` | `str` | `""` | Shown in `bk list-features`. Overwritten by the entry-point value at load time (e.g. `mypkg.features:RUST`). |
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
# mypkg/features.py
|
|
235
|
+
from collections.abc import Iterator, Sequence
|
|
236
|
+
from contextlib import contextmanager
|
|
237
|
+
from pathlib import Path
|
|
238
|
+
|
|
239
|
+
from buddelkiste.binds import ROBindConfig, RWBindConfig
|
|
240
|
+
from buddelkiste.features import Feature
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def rust_binds() -> list:
|
|
244
|
+
home = Path.home()
|
|
245
|
+
return [
|
|
246
|
+
RWBindConfig(home / ".cargo"),
|
|
247
|
+
ROBindConfig(home / ".rustup"),
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@contextmanager
|
|
252
|
+
def rust_setup() -> Iterator[Sequence[str]]:
|
|
253
|
+
# Optional: start helpers, yield extra bwrap args, clean up on exit.
|
|
254
|
+
# Built-in ssh uses this pattern for a dedicated ssh-agent.
|
|
255
|
+
yield []
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
RUST = Feature(
|
|
259
|
+
name="rust", # replaced by entry-point name "rust" when loaded
|
|
260
|
+
description="Rust toolchain directories",
|
|
261
|
+
default=False,
|
|
262
|
+
env_vars=("CARGO_HOME", "RUSTUP_HOME"),
|
|
263
|
+
binds=rust_binds,
|
|
264
|
+
setup=rust_setup, # or omit / None
|
|
265
|
+
origin="mypkg.features:RUST", # replaced by entry-point value when loaded
|
|
266
|
+
)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
```toml
|
|
270
|
+
# pyproject.toml
|
|
271
|
+
[project.entry-points."buddelkiste.features"]
|
|
272
|
+
rust = "mypkg.features:RUST"
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
After install, `bk list-features` shows the entry and its origin. Pure-TOML
|
|
276
|
+
`[feature.<name>]` covers env/bind cases without a package; use Python entry
|
|
277
|
+
points for custom bind logic or `setup` hooks. Config feature names must not
|
|
278
|
+
collide with an existing entry point.
|
|
279
|
+
|
|
280
|
+
## Network filter dependencies
|
|
281
|
+
|
|
282
|
+
`network.mode = "filter"` needs **pasta** (from `passt`) or **slirp4netns**, plus
|
|
283
|
+
**nft** and **setpriv**. No root and no reserved host subnets are required.
|
|
284
|
+
Hostname rules start a DNS proxy that redirects UDP/53 and updates dynamic
|
|
285
|
+
nftables allow sets from resolved A/AAAA records. Nameserver IPs from
|
|
286
|
+
`/etc/resolv.conf` are auto-allowed under a default-deny policy.
|
|
287
|
+
|
|
288
|
+
## Development
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
uv sync --group dev
|
|
292
|
+
uv run bk --help
|
|
293
|
+
uv run pytest # unit + nested-safe integ + coverage; TUN e2e skipped if unavailable
|
|
294
|
+
uv run pytest -m integration # real bwrap host/none + nested nft
|
|
295
|
+
uv run pytest -m requires_tun # filter/pasta e2e (needs /dev/net/tun)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Integration layout:
|
|
299
|
+
|
|
300
|
+
- `tests/` — unit/contract tests (mocked subprocess where needed)
|
|
301
|
+
- `tests/integ/` — nested-safe real `bwrap` (`host`/`none`), binds/env isolation, nested `nft` + DNS proxy/`nft add element`/`UDP/53` redirect against real tools
|
|
302
|
+
- `tests/integ_net/` — filter-mode e2e via pasta/slirp (allow/deny IP & host, guest caps); skipped without `/dev/net/tun`
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# buddelkiste
|
|
2
|
+
|
|
3
|
+
Run a command in a rootless sandbox (`bk`): filesystem and process isolation
|
|
4
|
+
via [bubblewrap](https://github.com/containers/bubblewrap), plus optional
|
|
5
|
+
network controls — share the host stack, cut connectivity entirely, or filter
|
|
6
|
+
egress with pasta/slirp4netns and in-namespace nftables (IP/CIDR and hostname
|
|
7
|
+
rules, no root required).
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Run a command in the sandbox
|
|
13
|
+
bk run /path/to/executable [args...]
|
|
14
|
+
|
|
15
|
+
# Inspect the sandbox (login shell)
|
|
16
|
+
bk run
|
|
17
|
+
|
|
18
|
+
# Catalog helpers
|
|
19
|
+
bk list-features
|
|
20
|
+
bk list-net-presets
|
|
21
|
+
|
|
22
|
+
# PATH shims for configured [executables.*] (in ~/.local/bin)
|
|
23
|
+
bk shims install
|
|
24
|
+
bk shims check
|
|
25
|
+
|
|
26
|
+
# Topic features (all on by default)
|
|
27
|
+
bk run --no-feature gui --no-feature google cursor-agent
|
|
28
|
+
bk run --feature python --feature ssh python myscript.py
|
|
29
|
+
|
|
30
|
+
# Network modes
|
|
31
|
+
bk run --network host … # share host network (default)
|
|
32
|
+
bk run --network none … # no connectivity
|
|
33
|
+
bk run --network filter \
|
|
34
|
+
--net-policy deny \
|
|
35
|
+
--net-allow 1.1.1.1/32 \
|
|
36
|
+
--net-allow api.github.com \
|
|
37
|
+
--net-allow '*.pypi.org' \
|
|
38
|
+
--net-deny-preset metadata \
|
|
39
|
+
--net-deny-preset private …
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`bk run` wrapper options: `--debug`, `--feature` / `--no-feature`,
|
|
43
|
+
`--network`, `--net-policy`, `--net-allow`, `--net-deny`, `--net-deny-preset`.
|
|
44
|
+
Everything else is the sandboxed command.
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
Optional permission sets are grouped by topic. Built-ins default to **on**.
|
|
49
|
+
Toggle with CLI flags or config; see `bk list-features` for the live catalog
|
|
50
|
+
(including each feature's module path).
|
|
51
|
+
|
|
52
|
+
| Feature | What it grants |
|
|
53
|
+
|---------|----------------|
|
|
54
|
+
| `asdf` | `~/.asdf`, `~/.tool-versions`, `ASDF_DIR` |
|
|
55
|
+
| `cursor` | Cursor IDE/CLI install and state dirs |
|
|
56
|
+
| `dbus` | session/system bus sockets, `DBUS_SESSION_BUS_ADDRESS` |
|
|
57
|
+
| `docker` | `~/.docker`, Docker socket / `DOCKER_HOST` |
|
|
58
|
+
| `git` | `~/.gitconfig`, `~/.config/git` |
|
|
59
|
+
| `google` | `/opt/google` |
|
|
60
|
+
| `gui` | display, GPU, audio, fonts, related env |
|
|
61
|
+
| `java` | OpenJDK `/etc/java-*-openjdk` configs |
|
|
62
|
+
| `locale` | `LANG`, `LC_NUMERIC`, `LC_TIME` |
|
|
63
|
+
| `node` | npm/nvm/bun paths, `BUN_INSTALL` |
|
|
64
|
+
| `nvim` | `~/nvim` |
|
|
65
|
+
| `python` | pip/virtualenv paths and env |
|
|
66
|
+
| `ssh` | `~/.ssh/config` plus a dedicated agent with `~/.ssh/sandbox_*` keys |
|
|
67
|
+
| `term` | `TERM`, `TERMINFO`, `COLORTERM`, `TERM_PROGRAM`, `EDITOR` |
|
|
68
|
+
| `user` | `~/.local` (ro), `~/.cache` (rw) |
|
|
69
|
+
| `xdg-open` | host `xdg-open` via flatpak-xdg-utils |
|
|
70
|
+
|
|
71
|
+
### CLI
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
bk list-features
|
|
75
|
+
|
|
76
|
+
# Tighten a desktop-ish run
|
|
77
|
+
bk run --no-feature gui --no-feature dbus --no-feature xdg-open curl https://example.com
|
|
78
|
+
|
|
79
|
+
# Minimal toolchains for a script
|
|
80
|
+
bk run --no-feature cursor --no-feature google --feature python --feature git python app.py
|
|
81
|
+
|
|
82
|
+
# Allowlist-style: disable broadly in config, then enable what you need
|
|
83
|
+
bk run --feature ssh --feature git git fetch
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Precedence (later wins for CLI flags): feature defaults → global config
|
|
87
|
+
`features` → per-executable `executables.<name>.features` →
|
|
88
|
+
`--feature` / `--no-feature`.
|
|
89
|
+
|
|
90
|
+
### Config
|
|
91
|
+
|
|
92
|
+
Configuration is read from `~/.config/buddelkiste/config.toml`, then merged with
|
|
93
|
+
`.buddelkiste.toml` from the current directory or an ancestor (project values
|
|
94
|
+
win). The project file is masked inside the sandbox (empty file bound over the
|
|
95
|
+
path) so the sandboxed command cannot read it.
|
|
96
|
+
|
|
97
|
+
```toml
|
|
98
|
+
# Global allowlist (exactly these features), or use a table of overrides:
|
|
99
|
+
# features = { gui = false, google = false }
|
|
100
|
+
features = ["git", "ssh", "python", "rust", "term", "locale", "user"]
|
|
101
|
+
|
|
102
|
+
[feature.rust]
|
|
103
|
+
description = "Rust toolchain directories"
|
|
104
|
+
default = false
|
|
105
|
+
env = ["CARGO_HOME", "RUSTUP_HOME"]
|
|
106
|
+
|
|
107
|
+
[[feature.rust.binds]]
|
|
108
|
+
source = "$CARGO_HOME"
|
|
109
|
+
mode = "rw"
|
|
110
|
+
|
|
111
|
+
[[feature.rust.binds]]
|
|
112
|
+
source = "${HOME}/.rustup"
|
|
113
|
+
mode = "ro"
|
|
114
|
+
|
|
115
|
+
[[feature.rust.binds]]
|
|
116
|
+
source = "${HOME}/.cargo/registry"
|
|
117
|
+
mode = "overlay" # persistent upper under $XDG_CACHE_HOME/buddelkiste/overlays/<hash>
|
|
118
|
+
|
|
119
|
+
[executables.cursor-agent]
|
|
120
|
+
features = ["cursor", "git", "ssh", "gui", "dbus", "xdg-open", "term"]
|
|
121
|
+
args = ["--force"]
|
|
122
|
+
|
|
123
|
+
[executables.cursor-agent.network]
|
|
124
|
+
mode = "filter"
|
|
125
|
+
policy = "deny"
|
|
126
|
+
allow = ["1.1.1.1/32", "api.github.com"]
|
|
127
|
+
|
|
128
|
+
[executables.python]
|
|
129
|
+
features = { python = true, git = true, gui = false }
|
|
130
|
+
shim = false
|
|
131
|
+
|
|
132
|
+
# shims = false # disable PATH shims globally (per-entry shim = true still wins)
|
|
133
|
+
|
|
134
|
+
[[binds]]
|
|
135
|
+
source = "/path/to/directory"
|
|
136
|
+
mode = "ro" # or "rw", "tmp-overlay", "overlay", "overlay:$XDG_CACHE_HOME/bk-upper"
|
|
137
|
+
|
|
138
|
+
[[binds]]
|
|
139
|
+
source = "${HOME}/scratch"
|
|
140
|
+
target = "/mnt/scratch"
|
|
141
|
+
mode = "tmp-overlay" # writable in the sandbox; host tree unchanged
|
|
142
|
+
|
|
143
|
+
[[envvars]]
|
|
144
|
+
name = "MYENVVAR"
|
|
145
|
+
value = "example value" # omit value= to take it from the process environment
|
|
146
|
+
|
|
147
|
+
[network]
|
|
148
|
+
mode = "filter"
|
|
149
|
+
policy = "deny"
|
|
150
|
+
allow = ["1.1.1.1/32", "api.github.com", "*.pypi.org"]
|
|
151
|
+
deny = ["203.0.113.0/24"]
|
|
152
|
+
deny_presets = ["metadata", "linklocal", "corp"]
|
|
153
|
+
|
|
154
|
+
[network.presets]
|
|
155
|
+
corp = ["10.50.0.0/16", "*.internal.example.com"]
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Bind `mode` values:
|
|
159
|
+
|
|
160
|
+
| Mode | Meaning |
|
|
161
|
+
|------|---------|
|
|
162
|
+
| `ro` | read-only bind (default) |
|
|
163
|
+
| `rw` | read-write bind |
|
|
164
|
+
| `tmp-overlay` | overlayfs; writes are ephemeral |
|
|
165
|
+
| `overlay` | overlayfs; upper under `$XDG_CACHE_HOME/buddelkiste/overlays/<sha256(source)>` |
|
|
166
|
+
| `overlay:<path>` | overlayfs with an explicit upper path (`$VAR` / `${VAR}` / `~` ok) |
|
|
167
|
+
|
|
168
|
+
Paths in `source`, `target`, and `overlay:<path>` may use `$VAR` / `${VAR}`.
|
|
169
|
+
|
|
170
|
+
The current working directory must be visible inside the sandbox. If it is not
|
|
171
|
+
covered by any bind, `bk` asks whether to whitelist it for this run or
|
|
172
|
+
permanently (appends a `[[binds]]` entry). Non-interactively it errors instead.
|
|
173
|
+
|
|
174
|
+
### PATH shims
|
|
175
|
+
|
|
176
|
+
For each `[executables.<name>]` entry with shimming enabled, `bk shims install`
|
|
177
|
+
writes a small `sh` wrapper into `$XDG_BIN_HOME` (default `~/.local/bin`). The
|
|
178
|
+
shim resolves the real binary (skipping its own directory) and runs
|
|
179
|
+
`bk run <real> "$@"`. Shims include a `# buddelkiste-shim:` marker so later
|
|
180
|
+
installs can refresh our own files without overwriting unrelated binaries.
|
|
181
|
+
|
|
182
|
+
Toggle with a global `shims` boolean (default `true`) and optional per-entry
|
|
183
|
+
`shim = true/false` overrides. Disabled entries remove a managed shim on the
|
|
184
|
+
next `install`.
|
|
185
|
+
|
|
186
|
+
```toml
|
|
187
|
+
shims = true
|
|
188
|
+
|
|
189
|
+
[executables.cursor-agent]
|
|
190
|
+
features = ["cursor"]
|
|
191
|
+
|
|
192
|
+
[executables.python]
|
|
193
|
+
shim = false
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
bk shims install # create/update/remove shims per config
|
|
198
|
+
bk shims check # warn if an original binary appears earlier on PATH
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Ensure `~/.local/bin` is early on your `PATH`. `bk shims check` exits non-zero
|
|
202
|
+
when something would bypass the sandbox.
|
|
203
|
+
|
|
204
|
+
### Python features (entry points)
|
|
205
|
+
|
|
206
|
+
Built-ins and third-party packages register features under the
|
|
207
|
+
`buddelkiste.features` entry-point group. The entry point value must be a
|
|
208
|
+
`Feature` instance, or a zero-argument callable that returns one. When loaded,
|
|
209
|
+
`name` and `origin` are set from the entry-point name and value
|
|
210
|
+
(e.g. `rust` / `mypkg.features:RUST`).
|
|
211
|
+
|
|
212
|
+
`Feature` fields:
|
|
213
|
+
|
|
214
|
+
| Field | Type | Default | Meaning |
|
|
215
|
+
|-------|------|---------|---------|
|
|
216
|
+
| `name` | `str` | *(required)* | Feature id used in CLI/config (`--feature`, `features = [...]`). Overwritten by the entry-point name at load time. |
|
|
217
|
+
| `description` | `str` | *(required)* | One-line summary shown by `bk list-features`. |
|
|
218
|
+
| `default` | `bool` | `True` | Whether the feature is enabled before config/CLI overrides. |
|
|
219
|
+
| `env_vars` | `tuple[str, ...]` | `()` | Host env var names to forward into the sandbox when the feature is on. |
|
|
220
|
+
| `binds` | `Callable[[], list]` | `lambda: []` | Zero-arg callable returning bind objects (`ROBindConfig`, `RWBindConfig`, `DevBindConfig`, overlay configs, `Tmpfs`, or raw bwrap arg tuples). Called each run. |
|
|
221
|
+
| `setup` | `Callable[[], AbstractContextManager[Sequence[str]]] \| None` | `None` | Optional factory returning a context manager. Entered while the sandbox runs; its yielded sequence is appended as extra bwrap args (binds, `--setenv`, …). Use for sockets/agents that need lifecycle. |
|
|
222
|
+
| `origin` | `str` | `""` | Shown in `bk list-features`. Overwritten by the entry-point value at load time (e.g. `mypkg.features:RUST`). |
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
# mypkg/features.py
|
|
226
|
+
from collections.abc import Iterator, Sequence
|
|
227
|
+
from contextlib import contextmanager
|
|
228
|
+
from pathlib import Path
|
|
229
|
+
|
|
230
|
+
from buddelkiste.binds import ROBindConfig, RWBindConfig
|
|
231
|
+
from buddelkiste.features import Feature
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def rust_binds() -> list:
|
|
235
|
+
home = Path.home()
|
|
236
|
+
return [
|
|
237
|
+
RWBindConfig(home / ".cargo"),
|
|
238
|
+
ROBindConfig(home / ".rustup"),
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@contextmanager
|
|
243
|
+
def rust_setup() -> Iterator[Sequence[str]]:
|
|
244
|
+
# Optional: start helpers, yield extra bwrap args, clean up on exit.
|
|
245
|
+
# Built-in ssh uses this pattern for a dedicated ssh-agent.
|
|
246
|
+
yield []
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
RUST = Feature(
|
|
250
|
+
name="rust", # replaced by entry-point name "rust" when loaded
|
|
251
|
+
description="Rust toolchain directories",
|
|
252
|
+
default=False,
|
|
253
|
+
env_vars=("CARGO_HOME", "RUSTUP_HOME"),
|
|
254
|
+
binds=rust_binds,
|
|
255
|
+
setup=rust_setup, # or omit / None
|
|
256
|
+
origin="mypkg.features:RUST", # replaced by entry-point value when loaded
|
|
257
|
+
)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```toml
|
|
261
|
+
# pyproject.toml
|
|
262
|
+
[project.entry-points."buddelkiste.features"]
|
|
263
|
+
rust = "mypkg.features:RUST"
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
After install, `bk list-features` shows the entry and its origin. Pure-TOML
|
|
267
|
+
`[feature.<name>]` covers env/bind cases without a package; use Python entry
|
|
268
|
+
points for custom bind logic or `setup` hooks. Config feature names must not
|
|
269
|
+
collide with an existing entry point.
|
|
270
|
+
|
|
271
|
+
## Network filter dependencies
|
|
272
|
+
|
|
273
|
+
`network.mode = "filter"` needs **pasta** (from `passt`) or **slirp4netns**, plus
|
|
274
|
+
**nft** and **setpriv**. No root and no reserved host subnets are required.
|
|
275
|
+
Hostname rules start a DNS proxy that redirects UDP/53 and updates dynamic
|
|
276
|
+
nftables allow sets from resolved A/AAAA records. Nameserver IPs from
|
|
277
|
+
`/etc/resolv.conf` are auto-allowed under a default-deny policy.
|
|
278
|
+
|
|
279
|
+
## Development
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
uv sync --group dev
|
|
283
|
+
uv run bk --help
|
|
284
|
+
uv run pytest # unit + nested-safe integ + coverage; TUN e2e skipped if unavailable
|
|
285
|
+
uv run pytest -m integration # real bwrap host/none + nested nft
|
|
286
|
+
uv run pytest -m requires_tun # filter/pasta e2e (needs /dev/net/tun)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Integration layout:
|
|
290
|
+
|
|
291
|
+
- `tests/` — unit/contract tests (mocked subprocess where needed)
|
|
292
|
+
- `tests/integ/` — nested-safe real `bwrap` (`host`/`none`), binds/env isolation, nested `nft` + DNS proxy/`nft add element`/`UDP/53` redirect against real tools
|
|
293
|
+
- `tests/integ_net/` — filter-mode e2e via pasta/slirp (allow/deny IP & host, guest caps); skipped without `/dev/net/tun`
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
set shell := ["bash", "-eu", "-o", "pipefail", "-c"]
|
|
2
|
+
|
|
3
|
+
# Create .venv and install the package with the dev dependency group (expects uv).
|
|
4
|
+
setup:
|
|
5
|
+
#!/usr/bin/env bash
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
cd "{{justfile_directory()}}"
|
|
8
|
+
if ! command -v uv >/dev/null 2>&1; then
|
|
9
|
+
echo "uv is required on PATH" >&2
|
|
10
|
+
exit 1
|
|
11
|
+
fi
|
|
12
|
+
uv sync --group dev
|
|
13
|
+
|
|
14
|
+
# Run the test suite (pass extra pytest args after --).
|
|
15
|
+
test *args:
|
|
16
|
+
cd "{{justfile_directory()}}" && uv run pytest {{args}}
|
|
17
|
+
|
|
18
|
+
# Lint with ruff (pass extra ruff args after --).
|
|
19
|
+
lint *args:
|
|
20
|
+
cd "{{justfile_directory()}}" && uv run ruff check src tests scripts {{args}}
|
|
21
|
+
|
|
22
|
+
# Typecheck with ty (pass extra ty args after --).
|
|
23
|
+
typecheck *args:
|
|
24
|
+
cd "{{justfile_directory()}}" && uv run ty check src {{args}}
|
|
25
|
+
|
|
26
|
+
# Tag and build the next release candidate (omit version to be prompted).
|
|
27
|
+
rc *args:
|
|
28
|
+
"{{justfile_directory()}}/scripts/release" --rc {{args}}
|
|
29
|
+
|
|
30
|
+
# Tag and build a release (omit version to be prompted with recent commits).
|
|
31
|
+
release *args:
|
|
32
|
+
"{{justfile_directory()}}/scripts/release" {{args}}
|