agent-leash 0.3.0__py3-none-any.whl

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,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-leash
3
+ Version: 0.3.0
4
+ Summary: Isolated sandbox for AI coding agents
5
+ Project-URL: Repository, https://github.com/mathieu-lacage/agent-leash.git
6
+ Project-URL: Issues, https://github.com/mathieu-lacage/agent-leash/issues
7
+ Author-email: Mathieu Lacage <mathieu.lacage@cutebugs.net>
8
+ Maintainer-email: Mathieu Lacage <mathieu.lacage@cutebugs.net>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: aiosqlite>=0.20
13
+ Requires-Dist: click>=8.1
14
+ Requires-Dist: fastapi>=0.111
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: mitmproxy>=10.3
17
+ Requires-Dist: uvicorn[standard]>=0.29
18
+ Provides-Extra: dev
@@ -0,0 +1,17 @@
1
+ aleash/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ aleash/bwrap.py,sha256=9U5SyPF1fYgYkpabajoeZEzWQPOYXNI7kY5cUBvPPWo,5953
3
+ aleash/cli.py,sha256=Eh-gZfCYECanyQ-UFawlFeckOFRcdSs1DVoG6HMYuKo,5666
4
+ aleash/db.py,sha256=uC8GKvNpZ6CalfQE4AHhv5TKC-8VddKVa4RX6Wzo6jg,7189
5
+ aleash/profiles.py,sha256=qos28gB5ENG_NskzHi-Hb2dllpQm3OnT4Yt_pBT7264,1695
6
+ aleash/proxy_addon.py,sha256=sYaCWzHI0eQlg9exUjKhYnO9nEkLcEIkA4ZwHXM4JL8,1617
7
+ aleash/runner.py,sha256=9LjjXtGV09BFSV9Mpuvn6zXSrIWA7g9MKkg5ZW1-48I,17333
8
+ aleash/server.py,sha256=seXOrBNY5JQZnUTgdOpjla5Qktndtc-Mf3qtcFzpCJc,20061
9
+ aleash/services.py,sha256=jkrLMfxaSujhbyN9Y4yPmphBxsLxiBO9_W_4M6dx2DI,3910
10
+ aleash/static/index.html,sha256=x36JhTRMoots4Q-rbxYCsGf5-WBSSc0SNd4VzLO0Tl4,371
11
+ aleash/static/assets/index-DZWOxijS.css,sha256=vfJ5yOABrOLquigwEjeWQGWNHqTahGv1ISCYFEuVmw8,10985
12
+ aleash/static/assets/index-gPIKKcCv.js,sha256=LDpUiEBOlfQqThu54BQMFOvX0suTpoFYi5rBA1Fs71A,374291
13
+ agent_leash-0.3.0.dist-info/METADATA,sha256=Yw1kS13v8UEhIqiMYiEYuchDr-xkQQ7G4vnRueS9ddY,647
14
+ agent_leash-0.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
15
+ agent_leash-0.3.0.dist-info/entry_points.txt,sha256=doBYgl7_2fdgFoWYPvnrMycQvk0Un4QTYjlTOA4E4PY,43
16
+ agent_leash-0.3.0.dist-info/licenses/LICENSE,sha256=zbOaTPxyhqlr08nomI4PGYiGhr4reJUD-WyR4lSUBn4,1071
17
+ agent_leash-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aleash = aleash.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mathieu Lacage
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
aleash/__init__.py ADDED
File without changes
aleash/bwrap.py ADDED
@@ -0,0 +1,218 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from .profiles import Profile
5
+
6
+ # Prefixes already mounted inside the sandbox — binary paths under these need no extra bind.
7
+ _MOUNTED_PREFIXES = (
8
+ "/usr",
9
+ "/etc",
10
+ "/lib",
11
+ "/lib64",
12
+ "/lib32",
13
+ "/sys",
14
+ "/run",
15
+ "/dev",
16
+ "/proc",
17
+ str(Path.home() / ".local" / "bin"),
18
+ )
19
+
20
+ # Real host-path bind mounts from the fixed section of build_bwrap_argv.
21
+ # Excludes --tmpfs, --proc, --symlink, and internal sockets/certs.
22
+ # Used by the server to display system mounts without duplicating this list.
23
+ SYSTEM_BIND_DISPLAY: list[tuple[str, str, str]] = [
24
+ ("/dev", "/dev", "rw"),
25
+ ("/usr", "/usr", "ro"),
26
+ ("/etc", "/etc", "ro"),
27
+ ("/lib", "/lib", "ro"),
28
+ ("/lib64", "/lib64", "ro"),
29
+ ("/lib32", "/lib32", "ro"),
30
+ ("/sys", "/sys", "ro"),
31
+ ]
32
+
33
+
34
+ def _outside_mounts(path: str) -> bool:
35
+ return not any(path == p or path.startswith(p + "/") for p in _MOUNTED_PREFIXES)
36
+
37
+
38
+ FLATPAK_INFO_CONTENT = """\
39
+ [Application]
40
+ name=fake.app.Id
41
+
42
+ [Instance]
43
+ app-id=fake.app.Id
44
+ """
45
+
46
+
47
+ def _uid() -> str:
48
+ return str(os.getuid())
49
+
50
+
51
+ def build_bwrap_argv(
52
+ profile: Profile,
53
+ cwd: str,
54
+ proxy_port: int,
55
+ xdg_proxy_sock: str,
56
+ ca_cert_path: str,
57
+ fake_flatpak_info: str,
58
+ cmd: list[str],
59
+ user_binds: list[tuple[str, str, str]] | None = None,
60
+ service_binds: list[tuple[str, str, str]] | None = None,
61
+ service_env: dict[str, str] | None = None,
62
+ ) -> list[str]:
63
+ uid = _uid()
64
+ ca_dest = "/tmp/aleash-ca.pem"
65
+ proxy_url = f"http://127.0.0.1:{proxy_port}"
66
+
67
+ args = [
68
+ "bwrap",
69
+ "--unshare-pid",
70
+ "--unshare-uts",
71
+ "--unshare-ipc",
72
+ "--share-net",
73
+ "--die-with-parent",
74
+ "--dev-bind",
75
+ "/dev",
76
+ "/dev",
77
+ "--ro-bind",
78
+ "/usr",
79
+ "/usr",
80
+ "--ro-bind",
81
+ "/etc",
82
+ "/etc",
83
+ "--ro-bind",
84
+ "/lib",
85
+ "/lib",
86
+ "--ro-bind-try",
87
+ "/lib64",
88
+ "/lib64",
89
+ "--ro-bind-try",
90
+ "/lib32",
91
+ "/lib32",
92
+ "--symlink",
93
+ "usr/bin",
94
+ "/bin",
95
+ "--symlink",
96
+ "usr/sbin",
97
+ "/sbin",
98
+ "--ro-bind",
99
+ "/sys",
100
+ "/sys",
101
+ "--tmpfs",
102
+ "/sys/fs/cgroup",
103
+ "--proc",
104
+ "/proc",
105
+ "--tmpfs",
106
+ f"/run/user/{uid}",
107
+ "--tmpfs",
108
+ "/tmp",
109
+ # CA cert: bind into /tmp (writable tmpfs, must come after --tmpfs /tmp above)
110
+ "--ro-bind",
111
+ ca_cert_path,
112
+ ca_dest,
113
+ # flatpak-info for portal auth
114
+ "--ro-bind",
115
+ fake_flatpak_info,
116
+ f"/run/user/{uid}/flatpak-info",
117
+ # xdg-dbus-proxy socket
118
+ "--bind",
119
+ xdg_proxy_sock,
120
+ xdg_proxy_sock,
121
+ # environment
122
+ "--setenv",
123
+ "http_proxy",
124
+ proxy_url,
125
+ "--setenv",
126
+ "HTTP_PROXY",
127
+ proxy_url,
128
+ "--setenv",
129
+ "https_proxy",
130
+ proxy_url,
131
+ "--setenv",
132
+ "HTTPS_PROXY",
133
+ proxy_url,
134
+ "--setenv",
135
+ "SSL_CERT_FILE",
136
+ ca_dest,
137
+ "--setenv",
138
+ "REQUESTS_CA_BUNDLE",
139
+ ca_dest,
140
+ "--setenv",
141
+ "NODE_EXTRA_CA_CERTS",
142
+ ca_dest,
143
+ "--setenv",
144
+ "DBUS_SESSION_BUS_ADDRESS",
145
+ f"unix:path={xdg_proxy_sock}",
146
+ ]
147
+
148
+ # Collect content binds then sort shortest-dest-first so parent RO mounts
149
+ # are always applied before child RW mounts. bwrap's --ro-bind uses
150
+ # MS_RDONLY|MS_REC which would retroactively RO any child mount that
151
+ # already existed — sorting prevents that.
152
+ content: list[tuple[str, str, str]] = [] # (flag, host, dest)
153
+
154
+ # ~/.local/bin — user-installed binaries (pipx, npm, cargo, etc.)
155
+ local_bin = Path.home() / ".local" / "bin"
156
+ if local_bin.is_dir():
157
+ content.append(("--ro-bind", str(local_bin), str(local_bin)))
158
+
159
+ # working directory (writable)
160
+ content.append(("--bind", cwd, cwd))
161
+
162
+ # profile extra binds
163
+ for host, dest in profile.extra_binds:
164
+ if Path(host).exists():
165
+ content.append(("--bind", host, dest))
166
+ for host, dest in profile.extra_ro_binds:
167
+ if Path(host).exists():
168
+ content.append(("--ro-bind", host, dest))
169
+
170
+ # profile ensure_home_dirs: bind real dirs (create if needed) into cwd/subpath
171
+ for rel in profile.ensure_home_dirs:
172
+ host_path = Path.home() / rel
173
+ host_path.mkdir(parents=True, exist_ok=True)
174
+ content.append(("--bind", str(host_path), str(Path(cwd) / rel)))
175
+
176
+ # user-configured extra binds (from .aleash-fs-binds.json)
177
+ for host, dest, mode in user_binds or []:
178
+ if Path(host).exists():
179
+ content.append(("--bind" if mode == "rw" else "--ro-bind", host, dest))
180
+
181
+ # service binds (from .aleash-services.json)
182
+ for host, dest, mode in service_binds or []:
183
+ if Path(host).exists():
184
+ content.append(("--bind" if mode == "rw" else "--ro-bind", host, dest))
185
+
186
+ content.sort(key=lambda b: len(b[2]))
187
+ for flag, host, dest in content:
188
+ args += [flag, host, dest]
189
+
190
+ args += ["--chdir", cwd]
191
+
192
+ # extra env from profile
193
+ for k, v in profile.extra_env.items():
194
+ args += ["--setenv", k, v]
195
+
196
+ # service env vars
197
+ for k, v in (service_env or {}).items():
198
+ args += ["--setenv", k, v]
199
+
200
+ # auto-bind the command binary if it lives outside already-mounted paths
201
+ # (e.g. ~/.local/bin/claude installed via pipx/npm)
202
+ binary = cmd[0]
203
+ if os.path.isabs(binary):
204
+ for p in dict.fromkeys(
205
+ [binary, os.path.realpath(binary)]
206
+ ): # dedup, preserve order
207
+ if _outside_mounts(p) and os.path.exists(p):
208
+ args += ["--ro-bind", p, p]
209
+
210
+ args += ["--", *cmd]
211
+ return args
212
+
213
+
214
+ def write_fake_flatpak_info(tmp_dir: str) -> str:
215
+ p = Path(tmp_dir) / "flatpak-info"
216
+ p.parent.mkdir(parents=True, exist_ok=True)
217
+ p.write_text(FLATPAK_INFO_CONTENT)
218
+ return str(p)
aleash/cli.py ADDED
@@ -0,0 +1,209 @@
1
+ import asyncio
2
+ import os
3
+ import socket
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ DAEMON_DIR = Path.home() / ".aleash"
10
+
11
+
12
+ def _free_port(preferred: int = 7612) -> int:
13
+ with socket.socket() as s:
14
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
15
+ try:
16
+ s.bind(("127.0.0.1", preferred))
17
+ return preferred
18
+ except OSError:
19
+ pass
20
+ with socket.socket() as s:
21
+ s.bind(("127.0.0.1", 0))
22
+ return s.getsockname()[1]
23
+
24
+
25
+ def _start_server_background(port: int):
26
+ import threading
27
+ import uvicorn
28
+ from .server import app as _app
29
+
30
+ ready = threading.Event()
31
+ config = uvicorn.Config(_app, host="127.0.0.1", port=port, log_level="warning")
32
+ server = uvicorn.Server(config)
33
+
34
+ orig_startup = server.startup
35
+
36
+ async def _patched_startup(sockets=None):
37
+ await orig_startup(sockets)
38
+ ready.set()
39
+
40
+ server.startup = _patched_startup
41
+ threading.Thread(target=lambda: asyncio.run(server.serve()), daemon=True).start()
42
+ ready.wait(timeout=5.0)
43
+ return server
44
+
45
+
46
+ @click.group()
47
+ @click.option(
48
+ "--profile", default=None, metavar="PROFILE", help="Override sandbox profile."
49
+ )
50
+ @click.option(
51
+ "--browser-master",
52
+ is_flag=True,
53
+ default=False,
54
+ help="Let the browser control terminal size.",
55
+ )
56
+ @click.pass_context
57
+ def main(ctx, profile, browser_master):
58
+ """Sandbox runner for AI coding agents."""
59
+ from . import db as _db
60
+
61
+ _db.DB_PATH = Path.cwd() / ".aleash" / "data.db"
62
+ ctx.ensure_object(dict)
63
+ ctx.obj["profile"] = profile
64
+ ctx.obj["browser_master"] = browser_master
65
+
66
+
67
+ @main.command(name="list")
68
+ def list_sandboxes():
69
+ """List sandboxes."""
70
+ import sqlite3
71
+ from .db import DB_PATH
72
+
73
+ if not DB_PATH.exists():
74
+ click.echo("No sandboxes yet.")
75
+ return
76
+ with sqlite3.connect(str(DB_PATH)) as conn:
77
+ conn.row_factory = sqlite3.Row
78
+ rows = conn.execute(
79
+ "SELECT * FROM sandboxes ORDER BY started_at DESC"
80
+ ).fetchall()
81
+ if not rows:
82
+ click.echo("No sandboxes yet.")
83
+ return
84
+ for s in rows:
85
+ status = "running" if s["ended_at"] is None else f"exited({s['exit_code']})"
86
+ click.echo(f"{s['id'][:8]} {status:12} {s['profile']:10} {s['cwd']}")
87
+
88
+
89
+ def _run_agent(
90
+ profile_name: str,
91
+ extra_args: tuple,
92
+ profile_override: str | None,
93
+ browser_master: bool = False,
94
+ ):
95
+ from .profiles import PROFILES
96
+ from .runner import run_sandbox
97
+
98
+ profile_key = profile_override or profile_name
99
+ if profile_key not in PROFILES:
100
+ click.echo(
101
+ f"Unknown profile '{profile_key}'. Available: {', '.join(PROFILES)}",
102
+ err=True,
103
+ )
104
+ sys.exit(1)
105
+ profile = PROFILES[profile_key]
106
+
107
+ import shutil
108
+
109
+ binary = shutil.which(profile_name)
110
+ if not binary:
111
+ click.echo(f"'{profile_name}' not found on PATH", err=True)
112
+ sys.exit(1)
113
+
114
+ port = _free_port()
115
+ server = _start_server_background(port)
116
+ click.echo(f"Sandbox UI available on http://localhost:{port}/")
117
+ import subprocess
118
+
119
+ subprocess.Popen(
120
+ ["xdg-open", f"http://localhost:{port}/"],
121
+ stdout=subprocess.DEVNULL,
122
+ stderr=subprocess.DEVNULL,
123
+ )
124
+ try:
125
+ exit_code = asyncio.run(
126
+ run_sandbox(
127
+ profile=profile,
128
+ cwd=os.getcwd(),
129
+ cmd=[binary, *extra_args],
130
+ browser_master=browser_master,
131
+ server_url=f"http://localhost:{port}",
132
+ )
133
+ )
134
+ finally:
135
+ server.should_exit = True
136
+ sys.exit(exit_code)
137
+
138
+
139
+ @main.command(
140
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True}
141
+ )
142
+ @click.argument("extra_args", nargs=-1, type=click.UNPROCESSED)
143
+ @click.pass_context
144
+ def claude(ctx, extra_args):
145
+ """Run claude in a sandbox."""
146
+ _run_agent(
147
+ "claude",
148
+ extra_args,
149
+ ctx.obj.get("profile"),
150
+ ctx.obj.get("browser_master", False),
151
+ )
152
+
153
+
154
+ @main.command(
155
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True}
156
+ )
157
+ @click.argument("extra_args", nargs=-1, type=click.UNPROCESSED)
158
+ @click.pass_context
159
+ def opencode(ctx, extra_args):
160
+ """Run opencode in a sandbox."""
161
+ _run_agent(
162
+ "opencode",
163
+ extra_args,
164
+ ctx.obj.get("profile"),
165
+ ctx.obj.get("browser_master", False),
166
+ )
167
+
168
+
169
+ @main.command(
170
+ name="run",
171
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
172
+ )
173
+ @click.argument("cmd", nargs=-1, type=click.UNPROCESSED, required=True)
174
+ @click.pass_context
175
+ def run_cmd(ctx, cmd):
176
+ """Run an arbitrary command in a sandbox."""
177
+ from .profiles import PROFILES
178
+ from .runner import run_sandbox
179
+
180
+ profile = ctx.obj.get("profile") or "generic"
181
+ if profile not in PROFILES:
182
+ click.echo(
183
+ f"Unknown profile '{profile}'. Available: {', '.join(PROFILES)}", err=True
184
+ )
185
+ sys.exit(1)
186
+
187
+ port = _free_port()
188
+ server = _start_server_background(port)
189
+ click.echo(f"Sandbox UI available on http://localhost:{port}/")
190
+ import subprocess
191
+
192
+ subprocess.Popen(
193
+ ["xdg-open", f"http://localhost:{port}/"],
194
+ stdout=subprocess.DEVNULL,
195
+ stderr=subprocess.DEVNULL,
196
+ )
197
+ try:
198
+ exit_code = asyncio.run(
199
+ run_sandbox(
200
+ profile=PROFILES[profile],
201
+ cwd=os.getcwd(),
202
+ cmd=list(cmd),
203
+ browser_master=ctx.obj.get("browser_master", False),
204
+ server_url=f"http://localhost:{port}",
205
+ )
206
+ )
207
+ finally:
208
+ server.should_exit = True
209
+ sys.exit(exit_code)