slopbox-env 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.
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.3
2
+ Name: slopbox-env
3
+ Version: 0.1.0
4
+ Summary: A secure-ish environment for running AI agents.
5
+ Requires-Dist: click>=8.4.2
6
+ Requires-Dist: pydantic>=2.13.4
7
+ Requires-Python: >=3.14
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Slopbox
11
+ **A secure-ish environment for running AI agents.**
12
+
13
+ The goal of this project is to make it very easy to _set up
14
+ an isolated runtime_ (container or virtual machine) for an AI agent.
15
+
16
+ What you can expect:
17
+ * intuitive configuration with sane defaults
18
+ * principle of least privilege
19
+ * include only the packages that are neccessary for the agent to operate
20
+ * restrict agent's network access through a customizable proxy
21
+ * grant access only to a subset of directories
22
+ * just works™
23
+
24
+ **DISCLAIMER**: this project is on a very early stage of development -- things will break, things will change.
25
+
26
+ ## Requirements
27
+ - Nix
28
+ - Podman or Docker
29
+ - rootless mode required
30
+ - guide for docker on NixOS: https://wiki.nixos.org/wiki/Docker#Rootless_Docker
31
+
32
+ ## Docs
33
+ No documentation is available at this point.
34
+
35
+ ## What's next
36
+ See the list of [planned features](/issues?q=is%3Aissue+state%3Aopen+label%3Aenhancement+author%3AjkbSeven)
@@ -0,0 +1,27 @@
1
+ # Slopbox
2
+ **A secure-ish environment for running AI agents.**
3
+
4
+ The goal of this project is to make it very easy to _set up
5
+ an isolated runtime_ (container or virtual machine) for an AI agent.
6
+
7
+ What you can expect:
8
+ * intuitive configuration with sane defaults
9
+ * principle of least privilege
10
+ * include only the packages that are neccessary for the agent to operate
11
+ * restrict agent's network access through a customizable proxy
12
+ * grant access only to a subset of directories
13
+ * just works™
14
+
15
+ **DISCLAIMER**: this project is on a very early stage of development -- things will break, things will change.
16
+
17
+ ## Requirements
18
+ - Nix
19
+ - Podman or Docker
20
+ - rootless mode required
21
+ - guide for docker on NixOS: https://wiki.nixos.org/wiki/Docker#Rootless_Docker
22
+
23
+ ## Docs
24
+ No documentation is available at this point.
25
+
26
+ ## What's next
27
+ See the list of [planned features](/issues?q=is%3Aissue+state%3Aopen+label%3Aenhancement+author%3AjkbSeven)
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "slopbox-env"
3
+ version = "0.1.0"
4
+ description = "A secure-ish environment for running AI agents."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "click>=8.4.2",
9
+ "pydantic>=2.13.4",
10
+ ]
11
+
12
+ [project.scripts]
13
+ slopbox = "slopbox.main:main"
14
+
15
+ [dependency-groups]
16
+ dev = ["pytest>=9.1.1"]
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.11.22,<0.12"]
20
+ build-backend = "uv_build"
21
+
22
+ [tool.uv.build-backend]
23
+ module-name = "slopbox"
24
+
25
+ [tool.ruff]
26
+ line-length = 88
27
+ indent-width = 4
28
+ target-version = "py314"
29
+
30
+ [tool.ruff.lint]
31
+ select = [
32
+ "E",
33
+ "F",
34
+ "I",
35
+ "UP",
36
+ "B",
37
+ "SIM",
38
+ ]
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "slopbox-env"
3
+ version = "0.1.0"
4
+ description = "A secure-ish environment for running AI agents."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "click>=8.4.2",
9
+ "pydantic>=2.13.4",
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "pytest>=9.1.1",
15
+ ]
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.11.22,<0.12"]
19
+ build-backend = "uv_build"
20
+
21
+ [tool.uv.build-backend]
22
+ module-name = "slopbox"
23
+
24
+ [project.scripts]
25
+ slopbox = "slopbox.main:main"
26
+
27
+ [tool.ruff]
28
+ line-length = 88
29
+ indent-width = 4
30
+ target-version = "py314"
31
+
32
+ [tool.ruff.lint]
33
+ select = [
34
+ "E", # pycodestyle
35
+ "F", # pyflakes
36
+ "I", # isort
37
+ "UP", # pyupgrade
38
+ "B", # flake8 bugbear
39
+ "SIM", # flake8 simplify
40
+ ]
File without changes
@@ -0,0 +1,91 @@
1
+ """
2
+ Configuration for slopbox as a CLI tool,
3
+ not for runtime container or vm configuration -- for that use environment module
4
+ """
5
+
6
+ import tomllib
7
+ import typing
8
+ from pathlib import Path
9
+
10
+ import pydantic
11
+
12
+ MAX_CONFIG_BYTES = 4096
13
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "slopbox" / "config.toml"
14
+
15
+
16
+ class ConfigError(Exception):
17
+ pass
18
+
19
+
20
+ class SlopboxPaths(typing.NamedTuple):
21
+ env: Path
22
+ lock: Path
23
+
24
+
25
+ class Config(pydantic.BaseModel):
26
+ slopbox_env_dir: Path = pydantic.Field(
27
+ default_factory=lambda: Path.home() / ".config" / "slopbox"
28
+ )
29
+
30
+ model_config = pydantic.ConfigDict(
31
+ extra="forbid",
32
+ validate_assignment=True,
33
+ )
34
+
35
+ @property
36
+ def slopbox_paths(self) -> SlopboxPaths:
37
+ return SlopboxPaths(
38
+ env=self.slopbox_env_dir / "slopbox.nix",
39
+ lock=self.slopbox_env_dir / "slopbox.lock",
40
+ )
41
+
42
+ @pydantic.field_validator("slopbox_env_dir", mode="after")
43
+ @classmethod
44
+ def __expand_slopbox_env_dir(cls, v: Path) -> Path:
45
+ return v.expanduser().resolve()
46
+
47
+ @classmethod
48
+ def load(cls, config_path: Path | None = None) -> Config:
49
+ if config_path is None:
50
+ config_path = DEFAULT_CONFIG_PATH
51
+
52
+ if not config_path.is_file():
53
+ return cls()
54
+
55
+ filesize = config_path.lstat().st_size
56
+
57
+ if filesize > MAX_CONFIG_BYTES:
58
+ raise ConfigError(
59
+ f"config file {config_path} has size of {filesize} bytes, "
60
+ f"maximum allowed size is {MAX_CONFIG_BYTES} bytes"
61
+ )
62
+
63
+ with config_path.open(mode="rb") as fp:
64
+ try:
65
+ toml_data = tomllib.load(fp)
66
+ except tomllib.TOMLDecodeError as err:
67
+ raise ConfigError(f"failed to load TOML file: {err}") from err
68
+
69
+ try:
70
+ c = cls.model_validate(toml_data)
71
+
72
+ except pydantic.ValidationError as err:
73
+ errors = err.errors(include_url=False)
74
+ errors_pretty = map(
75
+ lambda e: str({"field": e["loc"][0], "error": e["msg"]}), errors
76
+ )
77
+ raise ConfigError(
78
+ f"invalid configuration:\n{'\n'.join(errors_pretty)}"
79
+ ) from err
80
+
81
+ except Exception as err:
82
+ raise ConfigError(f"invalid configuration: {err}") from err
83
+
84
+ return c
85
+
86
+ def pretty_print(self) -> str:
87
+ # assumes flat config struct
88
+ return "\n".join([f"{k} = {v}" for k, v in self])
89
+
90
+
91
+ load = Config.load
@@ -0,0 +1,10 @@
1
+ from pathlib import Path
2
+
3
+ VERSION = "0.1.0"
4
+
5
+ PKG_ROOT = Path(__file__).parent.absolute()
6
+
7
+ NIX_ROOT = PKG_ROOT / "nix"
8
+ NIX_LIB_PATH = NIX_ROOT / "lib"
9
+
10
+ NIXPKGS_FETCH_URL = "github:nixos/nixpkgs/nixos-unstable"
@@ -0,0 +1,75 @@
1
+ import shutil
2
+ import subprocess
3
+ from typing import Protocol
4
+
5
+
6
+ class ContainerEngineError(Exception):
7
+ pass
8
+
9
+
10
+ class ContainerEngine(Protocol):
11
+ name: str
12
+
13
+ @classmethod
14
+ def is_available(cls) -> bool: ...
15
+
16
+ @classmethod
17
+ def is_rootless(cls) -> bool: ...
18
+
19
+
20
+ class Docker:
21
+ name = "docker"
22
+
23
+ @classmethod
24
+ def is_available(cls) -> bool:
25
+ return shutil.which("docker") is not None
26
+
27
+ @classmethod
28
+ def is_rootless(cls) -> bool:
29
+ if not cls.is_available():
30
+ raise ContainerEngineError("docker is not installed")
31
+
32
+ ret = subprocess.run(
33
+ ["docker", "info", "--format", "{{ .SecurityOptions }}"],
34
+ capture_output=True,
35
+ )
36
+
37
+ if ret.returncode != 0:
38
+ raise ContainerEngineError(
39
+ "error while checking if docker is rootless: "
40
+ "command 'docker info' failed: "
41
+ f"{ret.stderr.decode(encoding='utf-8')}"
42
+ )
43
+
44
+ clean = ret.stdout.decode(encoding="utf-8").strip("[]")
45
+
46
+ return "name=rootless" in clean
47
+
48
+
49
+ class Podman:
50
+ name = "podman"
51
+
52
+ @classmethod
53
+ def is_available(cls) -> bool:
54
+ return shutil.which("podman") is not None
55
+
56
+ @classmethod
57
+ def is_rootless(cls) -> bool:
58
+ if not cls.is_available():
59
+ raise ContainerEngineError("podman is not installed")
60
+
61
+ # TODO: check if having non-rootless podman is even possible
62
+ return True
63
+
64
+
65
+ def get_container_engine() -> ContainerEngine:
66
+ # in case user has both, we prefer podman
67
+ if Podman.is_available():
68
+ return Podman()
69
+
70
+ if Docker.is_available():
71
+ return Docker()
72
+
73
+ raise ContainerEngineError(
74
+ "neither podman nor docker is installed (not found in $PATH)"
75
+ )
@@ -0,0 +1,24 @@
1
+ EXAMPLE_ENV_CONFIG = """{
2
+ pkgs,
3
+ ...
4
+ }:
5
+ {
6
+ default = {
7
+ agent = pkgs.opencode;
8
+ workspace = "/workspace";
9
+ };
10
+
11
+ profiles = {
12
+ default = {
13
+ pkgs = [
14
+ pkgs.python314
15
+ pkgs.uv
16
+ ];
17
+
18
+ runHook = "echo 'Hello, world!'";
19
+ };
20
+ };
21
+
22
+ schemaVersion = 1;
23
+ }
24
+ """
@@ -0,0 +1,341 @@
1
+ import json
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from slopbox import config, const, container_engine, environment
8
+ from slopbox.nix import Nix, NixError, NixFetchTreeResult
9
+
10
+
11
+ class fmt:
12
+ esc = "\x1b"
13
+ checkmark = "\u2713"
14
+
15
+ @staticmethod
16
+ def red(msg: str, **kwargs) -> str:
17
+ return click.style(msg, fg="red", **kwargs)
18
+
19
+ @staticmethod
20
+ def green(msg: str, **kwargs) -> str:
21
+ return click.style(msg, fg="green", **kwargs)
22
+
23
+ @staticmethod
24
+ def up(count: int, *, goto_beg: bool) -> str:
25
+ return f"{fmt.esc}[{count}A" + ("\r" if goto_beg else "")
26
+
27
+ @staticmethod
28
+ def clear_line() -> str:
29
+ return f"{fmt.esc}[2K"
30
+
31
+
32
+ def _init_config_dir(config_dir: Path):
33
+ try:
34
+ config_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
35
+ except (OSError, FileNotFoundError) as err:
36
+ raise click.ClickException(
37
+ f"unable to create or read the config directory ({config_dir}): {err}"
38
+ ) from err
39
+
40
+
41
+ def _err_if_unhealthy() -> None:
42
+ if not Nix.is_available():
43
+ raise click.ClickException(
44
+ "You have to install Nix prior to using slopbox; "
45
+ "Nix main page: https://nixos.org/"
46
+ )
47
+
48
+ try:
49
+ cengine = container_engine.get_container_engine()
50
+
51
+ except container_engine.ContainerEngineError as err:
52
+ raise click.ClickException(
53
+ f"unable to resolve container engine: {err}"
54
+ ) from err
55
+
56
+ if not cengine.is_rootless():
57
+ raise click.ClickException(
58
+ f"{cengine.name} is not running in rootless mode, "
59
+ "this has security implications and slopbox cannot proceed"
60
+ )
61
+
62
+
63
+ @click.group()
64
+ @click.version_option(version=const.VERSION, message="%(version)s")
65
+ @click.option(
66
+ "--config",
67
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
68
+ help="Path to a config file (.toml)",
69
+ )
70
+ @click.pass_context
71
+ def cli(ctx: click.Context, config: Path | None):
72
+ """secure-ish environment for running AI agents"""
73
+ ctx.ensure_object(dict)
74
+ ctx.obj["config"] = config
75
+
76
+
77
+ @cli.command("health")
78
+ def cli_health():
79
+ """validate runtime health"""
80
+ nix_status: str
81
+ if not Nix.is_available():
82
+ nix_status = f"Nix: {fmt.red('NOT INSTALLED', bold=True)}"
83
+ else:
84
+ nix_status = f"Nix: {fmt.green('OK', bold=True)}"
85
+ click.echo(nix_status)
86
+
87
+ cengine_status: str
88
+ try:
89
+ cengine = container_engine.get_container_engine()
90
+ except container_engine.ContainerEngineError as err:
91
+ cengine_status = (
92
+ f"Container engine: {fmt.red('MISSING', bold=True)} (err: {err})"
93
+ )
94
+ else:
95
+ rootless_status = (
96
+ "(rootless)" if cengine.is_rootless() else fmt.red("(non-rootless)")
97
+ )
98
+ cengine_status = (
99
+ f"Container engine: {fmt.green(cengine.name, bold=True)} {rootless_status}"
100
+ )
101
+ click.echo(cengine_status)
102
+
103
+
104
+ @cli.command("build")
105
+ @click.option("--profile", "-p", default="default")
106
+ @click.option(
107
+ "--runtime",
108
+ "-r",
109
+ default="container",
110
+ type=click.Choice(["container", "vm"]),
111
+ )
112
+ @click.pass_context
113
+ def cli_build(ctx: click.Context, profile: str, runtime: str):
114
+ """build profile"""
115
+ if runtime != "container":
116
+ raise click.ClickException(f"'{runtime}' runtime is not supported yet")
117
+
118
+ _err_if_unhealthy()
119
+
120
+ c = config.load(ctx.obj["config"])
121
+
122
+ env_file, lock_file = c.slopbox_paths
123
+
124
+ if not lock_file.exists():
125
+ raise click.ClickException(
126
+ f"You haven't locked slopbox dependencies: {lock_file} does not exist. "
127
+ "Run `slopbox init` first, then rerun this command"
128
+ )
129
+
130
+ if not env_file.exists():
131
+ raise click.ClickException(
132
+ "You haven't created a slopbox environment: "
133
+ f"{env_file} does not exist. "
134
+ "Run `slopbox init`, adjust the environment to your needs, "
135
+ "then rerun this command"
136
+ )
137
+
138
+ lock = NixFetchTreeResult.model_validate_json(lock_file.read_text(encoding="utf-8"))
139
+ nixpkgs_url = lock.fetch_tree_url()
140
+
141
+ click.echo(f"Building profile '{profile}':")
142
+
143
+ try:
144
+ profile_data = Nix.profile_data(str(env_file), nixpkgs_url, profile)
145
+ except NixError as err:
146
+ raise click.ClickException(
147
+ f"failed to evaluate profile with nix: {err}"
148
+ ) from err
149
+
150
+ engine = container_engine.get_container_engine().name
151
+
152
+ def _build(component: str):
153
+ click.echo(f"-> building {component}...")
154
+ try:
155
+ loader_path = Nix.build_container_loader(
156
+ env_path=str(env_file),
157
+ nixpkgs_url=nixpkgs_url,
158
+ profile=profile,
159
+ engine=engine,
160
+ component=component,
161
+ )
162
+ except NixError as err:
163
+ raise click.ClickException(f"build failed: {err}") from err
164
+
165
+ result = subprocess.run(f"{loader_path}/bin/loader", capture_output=True)
166
+
167
+ if result.returncode != 0:
168
+ raise click.ClickException(
169
+ "failed to load the container image: "
170
+ + result.stderr.decode(encoding="utf-8")
171
+ )
172
+
173
+ click.echo(
174
+ fmt.up(1, goto_beg=True)
175
+ + fmt.clear_line()
176
+ + fmt.green(fmt.checkmark)
177
+ + f" built {component} ({profile_data['containers'][component]['image']})"
178
+ )
179
+
180
+ _build("runtime")
181
+ if profile_data["profile"]["proxy"]["enable"] is True:
182
+ _build("proxy")
183
+
184
+
185
+ @cli.command("init")
186
+ @click.pass_context
187
+ def cli_init(ctx: click.Context):
188
+ """initialize slopbox environment (user-wide)"""
189
+ if not Nix.is_available():
190
+ raise click.ClickException(
191
+ "You have to install Nix prior to using slopbox; "
192
+ "Nix main page: https://nixos.org/"
193
+ )
194
+
195
+ c = config.load(ctx.obj["config"])
196
+
197
+ _init_config_dir(c.slopbox_env_dir)
198
+
199
+ env_file, lock_file = c.slopbox_paths
200
+
201
+ if not lock_file.exists():
202
+ try:
203
+ nixpkgs_tree = Nix.fetch_tree(const.NIXPKGS_FETCH_URL)
204
+ except NixError as err:
205
+ raise click.ClickException(
206
+ f"failed to fetch and pin nixpkgs revision: {err}"
207
+ ) from err
208
+
209
+ lock_file.write_text(
210
+ nixpkgs_tree.model_dump_json(indent=2, by_alias=True),
211
+ encoding="utf-8",
212
+ )
213
+
214
+ if not env_file.exists():
215
+ env_file.write_text(environment.EXAMPLE_ENV_CONFIG, encoding="utf-8")
216
+
217
+
218
+ @cli.command("profile")
219
+ @click.argument("profile", default="default")
220
+ @click.pass_context
221
+ def cli_profile(ctx: click.Context, profile: str):
222
+ """print profile data and build metadata"""
223
+ _err_if_unhealthy()
224
+
225
+ c = config.load(ctx.obj["config"])
226
+
227
+ env_file, lock_file = c.slopbox_paths
228
+
229
+ if not lock_file.exists():
230
+ raise click.ClickException(
231
+ f"You haven't locked slopbox dependencies: {lock_file} does not exist. "
232
+ "Run `slopbox init` first, then rerun this command"
233
+ )
234
+
235
+ if not env_file.exists():
236
+ raise click.ClickException(
237
+ "You haven't created a slopbox environment: "
238
+ f"{env_file} does not exist. "
239
+ "Run `slopbox init`, adjust the environment to your needs, "
240
+ "then rerun this command"
241
+ )
242
+
243
+ lock = NixFetchTreeResult.model_validate_json(lock_file.read_text(encoding="utf-8"))
244
+ try:
245
+ profile_data = Nix.profile_data(str(env_file), lock.fetch_tree_url(), profile)
246
+ except NixError as err:
247
+ raise click.ClickException(
248
+ f"failed to evaluate profile with nix: {err}"
249
+ ) from err
250
+
251
+ click.echo(json.dumps(profile_data, indent=2))
252
+
253
+
254
+ @cli.command("run")
255
+ @click.option("--profile", "-p", default="default")
256
+ @click.option(
257
+ "--runtime",
258
+ "-r",
259
+ default="container",
260
+ type=click.Choice(["container", "vm"]),
261
+ )
262
+ @click.pass_context
263
+ def cli_run(ctx: click.Context, profile: str, runtime: str):
264
+ """run profile"""
265
+ if runtime != "container":
266
+ raise click.ClickException(f"'{runtime}' runtime is not supported yet")
267
+
268
+ _err_if_unhealthy()
269
+
270
+ c = config.load(ctx.obj["config"])
271
+ env_file, lock_file = c.slopbox_paths
272
+
273
+ if not lock_file.exists():
274
+ raise click.ClickException(
275
+ f"You haven't locked slopbox dependencies: {lock_file} does not exist. "
276
+ "Run `slopbox init` first, then rerun this command"
277
+ )
278
+
279
+ if not env_file.exists():
280
+ raise click.ClickException(
281
+ "You haven't created a slopbox environment: "
282
+ f"{env_file} does not exist. "
283
+ "Run `slopbox init`, adjust the environment to your needs, "
284
+ "then rerun this command"
285
+ )
286
+
287
+ lock = NixFetchTreeResult.model_validate_json(lock_file.read_text(encoding="utf-8"))
288
+ nixpkgs_url = lock.fetch_tree_url()
289
+ engine = container_engine.get_container_engine().name
290
+
291
+ runner_path = Nix.build_container_runner(
292
+ str(env_file), nixpkgs_url, profile, engine
293
+ )
294
+
295
+ result = subprocess.run(f"{runner_path}/bin/run")
296
+
297
+ if result.returncode != 0:
298
+ raise click.ClickException(
299
+ f"error during runtime, please see the {engine} logs above"
300
+ )
301
+
302
+
303
+ @cli.group("config")
304
+ def cli_config():
305
+ """manage tool configuration (slopbox CLI)"""
306
+
307
+
308
+ @cli_config.command("show")
309
+ @click.pass_context
310
+ def cli_config_show(ctx: click.Context):
311
+ """show resolved tool configuration"""
312
+ try:
313
+ c = config.load(ctx.obj["config"])
314
+
315
+ except config.ConfigError as err:
316
+ raise click.ClickException(f"error while loading tool config: {err}") from err
317
+
318
+ click.echo(c.pretty_print())
319
+
320
+
321
+ @cli_config.command("edit")
322
+ @click.pass_context
323
+ def cli_config_edit(ctx: click.Context):
324
+ """edit tool configuration in your text editor ($EDITOR)"""
325
+ p = ctx.obj["config"] or config.DEFAULT_CONFIG_PATH
326
+ p.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
327
+
328
+ if p.exists() and not p.is_file():
329
+ raise click.ClickException(
330
+ f"config path exists but does not point to a file: {p}"
331
+ )
332
+
333
+ click.edit(filename=str(p))
334
+
335
+
336
+ def main():
337
+ cli()
338
+
339
+
340
+ if __name__ == "__main__":
341
+ main()
@@ -0,0 +1,12 @@
1
+ {
2
+ nixpkgsUrl,
3
+ slopboxEnvPath,
4
+ profile,
5
+ engine,
6
+ component,
7
+ }:
8
+ let
9
+ lib = import ./lib;
10
+ cfg = (lib.config.eval { inherit nixpkgsUrl slopboxEnvPath; }).config;
11
+ in
12
+ cfg.profiles.${profile}.build.${component}.container-loader.${engine}