airlock-sandbox 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.
- airlock_sandbox-0.1.0/LICENSE +21 -0
- airlock_sandbox-0.1.0/PKG-INFO +132 -0
- airlock_sandbox-0.1.0/README.md +110 -0
- airlock_sandbox-0.1.0/pyproject.toml +38 -0
- airlock_sandbox-0.1.0/setup.cfg +4 -0
- airlock_sandbox-0.1.0/src/airlock_sandbox/__init__.py +41 -0
- airlock_sandbox-0.1.0/src/airlock_sandbox/_http.py +104 -0
- airlock_sandbox-0.1.0/src/airlock_sandbox/errors.py +72 -0
- airlock_sandbox-0.1.0/src/airlock_sandbox/py.typed +0 -0
- airlock_sandbox-0.1.0/src/airlock_sandbox/sandbox.py +248 -0
- airlock_sandbox-0.1.0/src/airlock_sandbox/tools.py +109 -0
- airlock_sandbox-0.1.0/src/airlock_sandbox.egg-info/PKG-INFO +132 -0
- airlock_sandbox-0.1.0/src/airlock_sandbox.egg-info/SOURCES.txt +14 -0
- airlock_sandbox-0.1.0/src/airlock_sandbox.egg-info/dependency_links.txt +1 -0
- airlock_sandbox-0.1.0/src/airlock_sandbox.egg-info/top_level.txt +1 -0
- airlock_sandbox-0.1.0/tests/test_offline.py +63 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 farvez
|
|
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.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: airlock-sandbox
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for agent-sandbox: isolated, network-controlled code execution for AI agents.
|
|
5
|
+
Author: farvez
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/farvez/agent-sandbox
|
|
8
|
+
Project-URL: Source, https://github.com/farvez/agent-sandbox/tree/main/sdk/python
|
|
9
|
+
Project-URL: Issues, https://github.com/farvez/agent-sandbox/issues
|
|
10
|
+
Keywords: ai,agents,sandbox,code-execution,gvisor,llm,tools
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# airlock-sandbox
|
|
24
|
+
|
|
25
|
+
Python SDK for [agent-sandbox](https://github.com/farvez/agent-sandbox): run code from
|
|
26
|
+
AI agents in isolated gVisor containers — no network unless you allow it, hard memory,
|
|
27
|
+
CPU, process and disk limits, and every outbound connection logged.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install airlock-sandbox
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
No dependencies; Python 3.9+.
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from airlock_sandbox import Sandbox
|
|
39
|
+
|
|
40
|
+
with Sandbox(api_key="...", base_url="https://sandbox.example.com") as sbx:
|
|
41
|
+
sbx.files.write("main.py", "print(2 ** 16)")
|
|
42
|
+
result = sbx.run("python3 main.py")
|
|
43
|
+
print(result.stdout) # 65536
|
|
44
|
+
print(result.exit_code) # 0
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Leaving the `with` block deletes the session and its files. Settings can come from the
|
|
48
|
+
environment instead: `SANDBOX_API_URL`, `SANDBOX_API_KEY`, and `SANDBOX_API_INSECURE=1`
|
|
49
|
+
for a server with a self-signed certificate (or pass `verify="/path/to/ca.pem"`).
|
|
50
|
+
|
|
51
|
+
## Running commands
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
result = sbx.run("python3 -m pytest -q", timeout=60) # 1–60 s
|
|
55
|
+
result.stdout, result.stderr, result.exit_code
|
|
56
|
+
result.ok # exit code 0, not timed out, not killed
|
|
57
|
+
result.timed_out # hit the timeout
|
|
58
|
+
result.oom_killed # killed for exceeding the memory limit
|
|
59
|
+
result.warnings # e.g. over the disk quota
|
|
60
|
+
result.check() # raises CommandError unless ok; returns the result
|
|
61
|
+
str(result) # text form with [STDOUT]/[STDERR]/[EXIT CODE] — handy for an LLM
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each command runs in a fresh container; files in the workspace persist between
|
|
65
|
+
commands, processes do not.
|
|
66
|
+
|
|
67
|
+
## Internet access (egress)
|
|
68
|
+
|
|
69
|
+
Sandboxes have no network by default. Ask for specific hosts — they must be allowed by
|
|
70
|
+
your tenant's policy on the server:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
with Sandbox(egress=["pypi"]) as sbx: # presets: pypi, npm, github, huggingface
|
|
74
|
+
sbx.run("pip install requests", timeout=60).check()
|
|
75
|
+
for event in sbx.egress_log():
|
|
76
|
+
print(event["decision"], event["host"], event.get("reason", ""))
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Only HTTPS to the listed hosts works; everything else is refused and logged.
|
|
80
|
+
`sbx.egress_policy()` lists what your tenant may request.
|
|
81
|
+
|
|
82
|
+
## Use as tools for an LLM agent
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from airlock_sandbox import Sandbox
|
|
86
|
+
from airlock_sandbox.tools import openai_tools, anthropic_tools, handle_tool_call
|
|
87
|
+
|
|
88
|
+
# OpenAI
|
|
89
|
+
response = client.chat.completions.create(model="gpt-4o-mini", messages=messages, tools=openai_tools())
|
|
90
|
+
for call in response.choices[0].message.tool_calls or []:
|
|
91
|
+
output = handle_tool_call(sbx, call.function.name, call.function.arguments)
|
|
92
|
+
|
|
93
|
+
# Anthropic
|
|
94
|
+
response = client.messages.create(model="claude-sonnet-5", max_tokens=2048, messages=messages, tools=anthropic_tools())
|
|
95
|
+
for block in response.content:
|
|
96
|
+
if block.type == "tool_use":
|
|
97
|
+
output = handle_tool_call(sbx, block.name, block.input)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Tools: `write_file`, `read_file`, `run_command`. `handle_tool_call` never raises — errors
|
|
101
|
+
come back as text the model can act on — and long output is trimmed to 8,000 characters,
|
|
102
|
+
keeping the beginning and the end.
|
|
103
|
+
|
|
104
|
+
## Errors
|
|
105
|
+
|
|
106
|
+
All errors inherit from `SandboxError`:
|
|
107
|
+
|
|
108
|
+
| Exception | When |
|
|
109
|
+
|-----------|------|
|
|
110
|
+
| `AuthenticationError` | Missing or invalid API key (401) |
|
|
111
|
+
| `PermissionDeniedError` | Path outside the workspace, egress outside your policy, someone else's session (403) |
|
|
112
|
+
| `NotFoundError` | Session doesn't exist or expired (404) |
|
|
113
|
+
| `QuotaExceededError` | Write would exceed the workspace disk quota (413) |
|
|
114
|
+
| `RateLimitError` | Tenant limit hit: sessions, requests/minute or concurrent commands (429); see `.retry_after` |
|
|
115
|
+
| `CapacityError` | Every workspace on the server is in use (503) |
|
|
116
|
+
| `ValidationError` | Invalid request, e.g. unknown template (400/422) |
|
|
117
|
+
| `APIConnectionError` | Server unreachable |
|
|
118
|
+
| `CommandError` | Raised by `CommandResult.check()` |
|
|
119
|
+
|
|
120
|
+
Rate limits (429) and a full server (503) are retried automatically, honouring the
|
|
121
|
+
server's `Retry-After` (`max_retries=3` by default).
|
|
122
|
+
|
|
123
|
+
## Account
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
sbx.usage() # {"limits": {...}, "sessions_open": 1, "commands_running": 0, "requests_available": 118}
|
|
127
|
+
sbx.health()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# airlock-sandbox
|
|
2
|
+
|
|
3
|
+
Python SDK for [agent-sandbox](https://github.com/farvez/agent-sandbox): run code from
|
|
4
|
+
AI agents in isolated gVisor containers — no network unless you allow it, hard memory,
|
|
5
|
+
CPU, process and disk limits, and every outbound connection logged.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install airlock-sandbox
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
No dependencies; Python 3.9+.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from airlock_sandbox import Sandbox
|
|
17
|
+
|
|
18
|
+
with Sandbox(api_key="...", base_url="https://sandbox.example.com") as sbx:
|
|
19
|
+
sbx.files.write("main.py", "print(2 ** 16)")
|
|
20
|
+
result = sbx.run("python3 main.py")
|
|
21
|
+
print(result.stdout) # 65536
|
|
22
|
+
print(result.exit_code) # 0
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Leaving the `with` block deletes the session and its files. Settings can come from the
|
|
26
|
+
environment instead: `SANDBOX_API_URL`, `SANDBOX_API_KEY`, and `SANDBOX_API_INSECURE=1`
|
|
27
|
+
for a server with a self-signed certificate (or pass `verify="/path/to/ca.pem"`).
|
|
28
|
+
|
|
29
|
+
## Running commands
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
result = sbx.run("python3 -m pytest -q", timeout=60) # 1–60 s
|
|
33
|
+
result.stdout, result.stderr, result.exit_code
|
|
34
|
+
result.ok # exit code 0, not timed out, not killed
|
|
35
|
+
result.timed_out # hit the timeout
|
|
36
|
+
result.oom_killed # killed for exceeding the memory limit
|
|
37
|
+
result.warnings # e.g. over the disk quota
|
|
38
|
+
result.check() # raises CommandError unless ok; returns the result
|
|
39
|
+
str(result) # text form with [STDOUT]/[STDERR]/[EXIT CODE] — handy for an LLM
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Each command runs in a fresh container; files in the workspace persist between
|
|
43
|
+
commands, processes do not.
|
|
44
|
+
|
|
45
|
+
## Internet access (egress)
|
|
46
|
+
|
|
47
|
+
Sandboxes have no network by default. Ask for specific hosts — they must be allowed by
|
|
48
|
+
your tenant's policy on the server:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
with Sandbox(egress=["pypi"]) as sbx: # presets: pypi, npm, github, huggingface
|
|
52
|
+
sbx.run("pip install requests", timeout=60).check()
|
|
53
|
+
for event in sbx.egress_log():
|
|
54
|
+
print(event["decision"], event["host"], event.get("reason", ""))
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Only HTTPS to the listed hosts works; everything else is refused and logged.
|
|
58
|
+
`sbx.egress_policy()` lists what your tenant may request.
|
|
59
|
+
|
|
60
|
+
## Use as tools for an LLM agent
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from airlock_sandbox import Sandbox
|
|
64
|
+
from airlock_sandbox.tools import openai_tools, anthropic_tools, handle_tool_call
|
|
65
|
+
|
|
66
|
+
# OpenAI
|
|
67
|
+
response = client.chat.completions.create(model="gpt-4o-mini", messages=messages, tools=openai_tools())
|
|
68
|
+
for call in response.choices[0].message.tool_calls or []:
|
|
69
|
+
output = handle_tool_call(sbx, call.function.name, call.function.arguments)
|
|
70
|
+
|
|
71
|
+
# Anthropic
|
|
72
|
+
response = client.messages.create(model="claude-sonnet-5", max_tokens=2048, messages=messages, tools=anthropic_tools())
|
|
73
|
+
for block in response.content:
|
|
74
|
+
if block.type == "tool_use":
|
|
75
|
+
output = handle_tool_call(sbx, block.name, block.input)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Tools: `write_file`, `read_file`, `run_command`. `handle_tool_call` never raises — errors
|
|
79
|
+
come back as text the model can act on — and long output is trimmed to 8,000 characters,
|
|
80
|
+
keeping the beginning and the end.
|
|
81
|
+
|
|
82
|
+
## Errors
|
|
83
|
+
|
|
84
|
+
All errors inherit from `SandboxError`:
|
|
85
|
+
|
|
86
|
+
| Exception | When |
|
|
87
|
+
|-----------|------|
|
|
88
|
+
| `AuthenticationError` | Missing or invalid API key (401) |
|
|
89
|
+
| `PermissionDeniedError` | Path outside the workspace, egress outside your policy, someone else's session (403) |
|
|
90
|
+
| `NotFoundError` | Session doesn't exist or expired (404) |
|
|
91
|
+
| `QuotaExceededError` | Write would exceed the workspace disk quota (413) |
|
|
92
|
+
| `RateLimitError` | Tenant limit hit: sessions, requests/minute or concurrent commands (429); see `.retry_after` |
|
|
93
|
+
| `CapacityError` | Every workspace on the server is in use (503) |
|
|
94
|
+
| `ValidationError` | Invalid request, e.g. unknown template (400/422) |
|
|
95
|
+
| `APIConnectionError` | Server unreachable |
|
|
96
|
+
| `CommandError` | Raised by `CommandResult.check()` |
|
|
97
|
+
|
|
98
|
+
Rate limits (429) and a full server (503) are retried automatically, honouring the
|
|
99
|
+
server's `Retry-After` (`max_retries=3` by default).
|
|
100
|
+
|
|
101
|
+
## Account
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
sbx.usage() # {"limits": {...}, "sessions_open": 1, "commands_running": 0, "requests_available": 118}
|
|
105
|
+
sbx.health()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "airlock-sandbox"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python SDK for agent-sandbox: isolated, network-controlled code execution for AI agents."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = [] # standard library only
|
|
14
|
+
authors = [{ name = "farvez" }]
|
|
15
|
+
keywords = ["ai", "agents", "sandbox", "code-execution", "gvisor", "llm", "tools"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
"Topic :: Security",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/farvez/agent-sandbox"
|
|
28
|
+
Source = "https://github.com/farvez/agent-sandbox/tree/main/sdk/python"
|
|
29
|
+
Issues = "https://github.com/farvez/agent-sandbox/issues"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.dynamic]
|
|
32
|
+
version = { attr = "airlock_sandbox.__version__" }
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
airlock_sandbox = ["py.typed"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Python SDK for agent-sandbox: isolated, network-controlled code execution for AI agents.
|
|
2
|
+
|
|
3
|
+
from airlock_sandbox import Sandbox
|
|
4
|
+
|
|
5
|
+
with Sandbox(egress=["pypi"]) as sbx:
|
|
6
|
+
sbx.run("pip install requests", timeout=60).check()
|
|
7
|
+
sbx.files.write("main.py", "import requests; print(requests.__version__)")
|
|
8
|
+
print(sbx.run("python3 main.py").stdout)
|
|
9
|
+
"""
|
|
10
|
+
from airlock_sandbox.errors import (
|
|
11
|
+
APIConnectionError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
CapacityError,
|
|
14
|
+
CommandError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
PermissionDeniedError,
|
|
17
|
+
QuotaExceededError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
SandboxError,
|
|
20
|
+
ValidationError,
|
|
21
|
+
)
|
|
22
|
+
from airlock_sandbox.sandbox import CommandResult, Files, Sandbox
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Sandbox",
|
|
28
|
+
"CommandResult",
|
|
29
|
+
"Files",
|
|
30
|
+
"SandboxError",
|
|
31
|
+
"APIConnectionError",
|
|
32
|
+
"AuthenticationError",
|
|
33
|
+
"CapacityError",
|
|
34
|
+
"CommandError",
|
|
35
|
+
"NotFoundError",
|
|
36
|
+
"PermissionDeniedError",
|
|
37
|
+
"QuotaExceededError",
|
|
38
|
+
"RateLimitError",
|
|
39
|
+
"ValidationError",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Small HTTP client on the standard library, with retries for 429/503 and dropped connections."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import platform
|
|
6
|
+
import socket
|
|
7
|
+
import ssl
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any, Optional, Union
|
|
12
|
+
|
|
13
|
+
from airlock_sandbox.errors import STATUS_ERRORS, APIConnectionError, RateLimitError, SandboxError
|
|
14
|
+
|
|
15
|
+
IDEMPOTENT = {"GET", "DELETE"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HTTPClient:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_url: str,
|
|
22
|
+
api_key: str,
|
|
23
|
+
*,
|
|
24
|
+
verify: Union[bool, str] = True,
|
|
25
|
+
timeout: float = 90,
|
|
26
|
+
max_retries: int = 3,
|
|
27
|
+
max_retry_wait: float = 30,
|
|
28
|
+
user_agent: str = "",
|
|
29
|
+
):
|
|
30
|
+
self.base_url = base_url.rstrip("/")
|
|
31
|
+
self.api_key = api_key
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
self.max_retries = max_retries
|
|
34
|
+
self.max_retry_wait = max_retry_wait
|
|
35
|
+
self.user_agent = user_agent or f"airlock-sandbox python/{platform.python_version()}"
|
|
36
|
+
if verify is False:
|
|
37
|
+
# For a server without a domain (self-signed certificate). Prefer passing the CA file.
|
|
38
|
+
self._ssl: Optional[ssl.SSLContext] = ssl._create_unverified_context()
|
|
39
|
+
elif isinstance(verify, str):
|
|
40
|
+
self._ssl = ssl.create_default_context(cafile=verify)
|
|
41
|
+
else:
|
|
42
|
+
self._ssl = None
|
|
43
|
+
|
|
44
|
+
def request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
|
|
45
|
+
attempt = 0
|
|
46
|
+
while True:
|
|
47
|
+
try:
|
|
48
|
+
return self._send(method, path, body)
|
|
49
|
+
except SandboxError as err:
|
|
50
|
+
wait = self._retry_wait(err, method, attempt)
|
|
51
|
+
if wait is None:
|
|
52
|
+
raise
|
|
53
|
+
attempt += 1
|
|
54
|
+
time.sleep(wait)
|
|
55
|
+
|
|
56
|
+
def _retry_wait(self, err: SandboxError, method: str, attempt: int) -> Optional[float]:
|
|
57
|
+
"""Seconds to wait before retrying, or None to give up."""
|
|
58
|
+
if attempt >= self.max_retries:
|
|
59
|
+
return None
|
|
60
|
+
backoff = min(2 ** attempt, self.max_retry_wait)
|
|
61
|
+
if isinstance(err, RateLimitError):
|
|
62
|
+
# Not processed, so safe to retry any method. Honour Retry-After when sent.
|
|
63
|
+
wait = err.retry_after if err.retry_after is not None else backoff
|
|
64
|
+
return wait if wait <= self.max_retry_wait else None
|
|
65
|
+
if err.status == 503:
|
|
66
|
+
return backoff # no free workspace yet; nothing was created
|
|
67
|
+
if isinstance(err, APIConnectionError) and method in IDEMPOTENT:
|
|
68
|
+
return backoff
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def _send(self, method: str, path: str, body: Optional[dict]) -> Any:
|
|
72
|
+
req = urllib.request.Request(
|
|
73
|
+
self.base_url + path,
|
|
74
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
75
|
+
headers={
|
|
76
|
+
"X-API-Key": self.api_key,
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"Accept": "application/json",
|
|
79
|
+
"User-Agent": self.user_agent,
|
|
80
|
+
},
|
|
81
|
+
method=method,
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
with urllib.request.urlopen(req, context=self._ssl, timeout=self.timeout) as resp:
|
|
85
|
+
raw = resp.read()
|
|
86
|
+
except urllib.error.HTTPError as err:
|
|
87
|
+
raise self._http_error(err) from None
|
|
88
|
+
except (urllib.error.URLError, socket.timeout, ConnectionError, ssl.SSLError) as err:
|
|
89
|
+
reason = getattr(err, "reason", err)
|
|
90
|
+
raise APIConnectionError(f"Could not reach {self.base_url}: {reason}") from None
|
|
91
|
+
return json.loads(raw) if raw else None
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _http_error(err: urllib.error.HTTPError) -> SandboxError:
|
|
95
|
+
try:
|
|
96
|
+
detail = json.loads(err.read().decode()).get("detail", err.reason)
|
|
97
|
+
except Exception:
|
|
98
|
+
detail = err.reason
|
|
99
|
+
message = detail if isinstance(detail, str) else json.dumps(detail)
|
|
100
|
+
cls = STATUS_ERRORS.get(err.code, SandboxError)
|
|
101
|
+
if cls is RateLimitError:
|
|
102
|
+
header = err.headers.get("Retry-After")
|
|
103
|
+
return RateLimitError(message, detail=detail, retry_after=int(header) if header and header.isdigit() else None)
|
|
104
|
+
return cls(message, status=err.code, detail=detail)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Exceptions raised by the SDK. All inherit from SandboxError."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SandboxError(Exception):
|
|
8
|
+
"""Base class. `status` is the HTTP status when the server answered, else None."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str, status: Optional[int] = None, detail: object = None):
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.status = status
|
|
13
|
+
self.detail = detail if detail is not None else message
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIConnectionError(SandboxError):
|
|
17
|
+
"""The server could not be reached (DNS, TLS, refused connection, timeout)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthenticationError(SandboxError):
|
|
21
|
+
"""401: missing or invalid API key."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PermissionDeniedError(SandboxError, PermissionError):
|
|
25
|
+
"""403: path outside the workspace, egress outside the tenant policy, or another tenant's session."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotFoundError(SandboxError):
|
|
29
|
+
"""404: the session doesn't exist or has expired."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class QuotaExceededError(SandboxError):
|
|
33
|
+
"""413: the write would take the workspace past its disk quota."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ValidationError(SandboxError):
|
|
37
|
+
"""400/422: the request was rejected as invalid (e.g. unknown template, timeout out of range)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RateLimitError(SandboxError):
|
|
41
|
+
"""429: a per-tenant limit was hit (sessions, requests per minute, or concurrent commands).
|
|
42
|
+
|
|
43
|
+
`retry_after` is the server's suggested wait in seconds, when it sent one.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, status: int = 429, detail: object = None, retry_after: Optional[int] = None):
|
|
47
|
+
super().__init__(message, status, detail)
|
|
48
|
+
self.retry_after = retry_after
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CapacityError(SandboxError):
|
|
52
|
+
"""503: every workspace on the server is in use."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CommandError(SandboxError):
|
|
56
|
+
"""Raised by CommandResult.check() when a command failed, timed out or was killed."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, message: str, result: "object"):
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
self.result = result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
STATUS_ERRORS = {
|
|
64
|
+
400: ValidationError,
|
|
65
|
+
401: AuthenticationError,
|
|
66
|
+
403: PermissionDeniedError,
|
|
67
|
+
404: NotFoundError,
|
|
68
|
+
413: QuotaExceededError,
|
|
69
|
+
422: ValidationError,
|
|
70
|
+
429: RateLimitError,
|
|
71
|
+
503: CapacityError,
|
|
72
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""The Sandbox: one isolated session on an agent-sandbox server."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import List, Optional, Union
|
|
9
|
+
|
|
10
|
+
from airlock_sandbox._http import HTTPClient
|
|
11
|
+
from airlock_sandbox.errors import CommandError, SandboxError
|
|
12
|
+
|
|
13
|
+
DEFAULT_TEMPLATE = "sandbox-base:latest"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class CommandResult:
|
|
18
|
+
"""What a command did. `output` is the server's text form, handy to hand to an LLM."""
|
|
19
|
+
|
|
20
|
+
command: str
|
|
21
|
+
stdout: str
|
|
22
|
+
stderr: str
|
|
23
|
+
exit_code: int
|
|
24
|
+
timed_out: bool = False
|
|
25
|
+
oom_killed: bool = False
|
|
26
|
+
warnings: List[str] = field(default_factory=list)
|
|
27
|
+
output: str = ""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def ok(self) -> bool:
|
|
31
|
+
return self.exit_code == 0 and not self.timed_out and not self.oom_killed
|
|
32
|
+
|
|
33
|
+
def check(self) -> "CommandResult":
|
|
34
|
+
"""Raises CommandError unless the command succeeded; returns self so it can be chained."""
|
|
35
|
+
if not self.ok:
|
|
36
|
+
reason = "timed out" if self.timed_out else "was killed for using too much memory" if self.oom_killed \
|
|
37
|
+
else f"exited with code {self.exit_code}"
|
|
38
|
+
raise CommandError(f"Command {self.command!r} {reason}.\n{self.output}", result=self)
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
return self.output
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_response(cls, data: dict) -> "CommandResult":
|
|
46
|
+
if "exit_code" in data:
|
|
47
|
+
return cls(
|
|
48
|
+
command=data.get("command", ""),
|
|
49
|
+
stdout=data.get("stdout", ""),
|
|
50
|
+
stderr=data.get("stderr", ""),
|
|
51
|
+
exit_code=data["exit_code"],
|
|
52
|
+
timed_out=data.get("timed_out", False),
|
|
53
|
+
oom_killed=data.get("oom_killed", False),
|
|
54
|
+
warnings=list(data.get("warnings", [])),
|
|
55
|
+
output=data.get("output", ""),
|
|
56
|
+
)
|
|
57
|
+
return cls._from_text(data.get("command", ""), data.get("output", ""))
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def _from_text(cls, command: str, output: str) -> "CommandResult":
|
|
61
|
+
"""Older servers only return the text form; split it back into its sections."""
|
|
62
|
+
sections = {"STDOUT": [], "STDERR": []}
|
|
63
|
+
warnings, exit_code, timed_out, current = [], -1, False, None
|
|
64
|
+
for line in output.split("\n"):
|
|
65
|
+
marker = re.match(r"^\[(STDOUT|STDERR|WARNING|TIMEOUT|EXIT CODE)\]:\s?(.*)$", line)
|
|
66
|
+
if not marker:
|
|
67
|
+
if current:
|
|
68
|
+
sections[current].append(line)
|
|
69
|
+
continue
|
|
70
|
+
name, rest = marker.groups()
|
|
71
|
+
current = name if name in sections else None
|
|
72
|
+
if name in ("WARNING", "TIMEOUT"):
|
|
73
|
+
warnings.append(rest)
|
|
74
|
+
timed_out = timed_out or name == "TIMEOUT"
|
|
75
|
+
elif name == "EXIT CODE" and re.fullmatch(r"-?\d+", rest.strip()):
|
|
76
|
+
exit_code = int(rest)
|
|
77
|
+
stdout = "\n".join(sections["STDOUT"])
|
|
78
|
+
stderr = "\n".join(sections["STDERR"])
|
|
79
|
+
return cls(
|
|
80
|
+
command=command,
|
|
81
|
+
stdout=stdout + "\n" if stdout else "",
|
|
82
|
+
stderr=stderr + "\n" if stderr else "",
|
|
83
|
+
exit_code=exit_code,
|
|
84
|
+
timed_out=timed_out,
|
|
85
|
+
oom_killed=any("Memory limit" in w for w in warnings),
|
|
86
|
+
warnings=warnings,
|
|
87
|
+
output=output,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Files:
|
|
92
|
+
"""File operations inside the sandbox workspace (paths are relative to /workspace)."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, sandbox: "Sandbox"):
|
|
95
|
+
self._sandbox = sandbox
|
|
96
|
+
|
|
97
|
+
def write(self, path: str, content: str) -> None:
|
|
98
|
+
self._sandbox._call("POST", "/write", {"path": path, "content": content})
|
|
99
|
+
|
|
100
|
+
def read(self, path: str) -> str:
|
|
101
|
+
"""Returns the file's text. Raises FileNotFoundError if it doesn't exist."""
|
|
102
|
+
content = self._sandbox._call("GET", f"/read?{urllib.parse.urlencode({'path': path})}")["content"]
|
|
103
|
+
if content == f"Error: File '{path}' does not exist.":
|
|
104
|
+
raise FileNotFoundError(path)
|
|
105
|
+
return content
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _env_flag(name: str) -> bool:
|
|
109
|
+
return os.getenv(name, "").strip().lower() in {"1", "true", "yes"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Sandbox:
|
|
113
|
+
"""An isolated workspace on an agent-sandbox server.
|
|
114
|
+
|
|
115
|
+
with Sandbox(egress=["pypi"]) as sbx:
|
|
116
|
+
sbx.files.write("main.py", "print(2 ** 16)")
|
|
117
|
+
print(sbx.run("python3 main.py").stdout)
|
|
118
|
+
|
|
119
|
+
Connection settings default to SANDBOX_API_URL, SANDBOX_API_KEY and
|
|
120
|
+
SANDBOX_API_INSECURE=1 (accept a self-signed certificate).
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
api_key: tenant API key.
|
|
124
|
+
base_url: server URL, e.g. "https://sandbox.example.com".
|
|
125
|
+
egress: hosts or presets ("pypi", "npm", "github", "huggingface") this
|
|
126
|
+
session may reach over HTTPS. Empty means no network at all.
|
|
127
|
+
template: container image; must be on the server's allowlist.
|
|
128
|
+
verify: True (default), False to accept a self-signed certificate, or a
|
|
129
|
+
path to a CA bundle that signed the server's certificate.
|
|
130
|
+
timeout: seconds to wait for each HTTP response.
|
|
131
|
+
max_retries: retries for rate limits (429), full servers (503) and
|
|
132
|
+
dropped connections on idempotent calls.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
api_key: Optional[str] = None,
|
|
138
|
+
base_url: Optional[str] = None,
|
|
139
|
+
*,
|
|
140
|
+
egress: Optional[List[str]] = None,
|
|
141
|
+
template: str = DEFAULT_TEMPLATE,
|
|
142
|
+
verify: Optional[Union[bool, str]] = None,
|
|
143
|
+
timeout: float = 90,
|
|
144
|
+
max_retries: int = 3,
|
|
145
|
+
):
|
|
146
|
+
base_url = base_url or os.getenv("SANDBOX_API_URL")
|
|
147
|
+
api_key = api_key or os.getenv("SANDBOX_API_KEY")
|
|
148
|
+
if not base_url or not api_key:
|
|
149
|
+
raise SandboxError("Set base_url and api_key, or the SANDBOX_API_URL and SANDBOX_API_KEY environment variables.")
|
|
150
|
+
if verify is None:
|
|
151
|
+
verify = not _env_flag("SANDBOX_API_INSECURE")
|
|
152
|
+
|
|
153
|
+
from airlock_sandbox import __version__ # late import: avoids a cycle
|
|
154
|
+
|
|
155
|
+
self._http = HTTPClient(base_url, api_key, verify=verify, timeout=timeout, max_retries=max_retries,
|
|
156
|
+
user_agent=f"airlock-sandbox/{__version__}")
|
|
157
|
+
self.template = template
|
|
158
|
+
self.requested_egress = list(egress or [])
|
|
159
|
+
self.files = Files(self)
|
|
160
|
+
self.session_id: Optional[str] = None
|
|
161
|
+
self.tenant_id: Optional[str] = None
|
|
162
|
+
self.egress: List[str] = []
|
|
163
|
+
self.disk_quota_mb: Optional[int] = None
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def create(cls, *args, **kwargs) -> "Sandbox":
|
|
167
|
+
"""Creates and starts a sandbox. Remember to close() it, or use `with Sandbox(...)`."""
|
|
168
|
+
return cls(*args, **kwargs).start()
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------ lifecycle
|
|
171
|
+
|
|
172
|
+
def start(self) -> "Sandbox":
|
|
173
|
+
if self.session_id:
|
|
174
|
+
return self
|
|
175
|
+
data = self._http.request("POST", "/v1/sessions", {"template": self.template, "egress": self.requested_egress})
|
|
176
|
+
self.session_id = data["session_id"]
|
|
177
|
+
self.tenant_id = data.get("tenant_id")
|
|
178
|
+
self.egress = data.get("egress", [])
|
|
179
|
+
self.disk_quota_mb = data.get("disk_quota_mb")
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def close(self) -> None:
|
|
183
|
+
"""Deletes the session and its files. Safe to call more than once."""
|
|
184
|
+
if not self.session_id:
|
|
185
|
+
return
|
|
186
|
+
session_id, self.session_id = self.session_id, None
|
|
187
|
+
try:
|
|
188
|
+
self._http.request("DELETE", f"/v1/sessions/{session_id}")
|
|
189
|
+
except SandboxError as err:
|
|
190
|
+
if err.status != 404: # already gone (expired) is fine
|
|
191
|
+
raise
|
|
192
|
+
|
|
193
|
+
def __enter__(self) -> "Sandbox":
|
|
194
|
+
return self.start()
|
|
195
|
+
|
|
196
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
197
|
+
self.close()
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def base_url(self) -> str:
|
|
201
|
+
return self._http.base_url
|
|
202
|
+
|
|
203
|
+
def __repr__(self) -> str:
|
|
204
|
+
state = self.session_id or "not started"
|
|
205
|
+
return f"<Sandbox {state} tenant={self.tenant_id} egress={self.egress}>"
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------------ work
|
|
208
|
+
|
|
209
|
+
def run(self, command: str, timeout: int = 15) -> CommandResult:
|
|
210
|
+
"""Runs a shell command in a fresh container (1–60 s timeout). Files persist between runs."""
|
|
211
|
+
data = self._call("POST", "/exec", {"command": command, "timeout_seconds": timeout})
|
|
212
|
+
return CommandResult.from_response(data)
|
|
213
|
+
|
|
214
|
+
def egress_log(self, limit: int = 200) -> List[dict]:
|
|
215
|
+
"""Every outbound connection this session attempted, allowed or denied."""
|
|
216
|
+
return self._call("GET", f"/egress?limit={int(limit)}")["events"]
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------ account
|
|
219
|
+
|
|
220
|
+
def usage(self) -> dict:
|
|
221
|
+
"""This tenant's limits and current usage."""
|
|
222
|
+
return self._http.request("GET", "/v1/usage")
|
|
223
|
+
|
|
224
|
+
def egress_policy(self) -> List[str]:
|
|
225
|
+
"""Hosts this tenant's sessions may request."""
|
|
226
|
+
return self._http.request("GET", "/v1/egress/policy")["allowed"]
|
|
227
|
+
|
|
228
|
+
def health(self) -> dict:
|
|
229
|
+
return self._http.request("GET", "/healthz")
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------ agent-loop compatibility
|
|
232
|
+
|
|
233
|
+
def read_file(self, path: str) -> str:
|
|
234
|
+
return self.files.read(path)
|
|
235
|
+
|
|
236
|
+
def write_file(self, path: str, content: str) -> str:
|
|
237
|
+
self.files.write(path, content)
|
|
238
|
+
return f"Successfully wrote {len(content)} characters to {path}"
|
|
239
|
+
|
|
240
|
+
def run_command(self, command: str, timeout_seconds: int = 15) -> str:
|
|
241
|
+
return self.run(command, timeout=timeout_seconds).output
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------ internals
|
|
244
|
+
|
|
245
|
+
def _call(self, method: str, suffix: str, body: Optional[dict] = None):
|
|
246
|
+
if not self.session_id:
|
|
247
|
+
raise SandboxError("Sandbox not started: use `with Sandbox(...)` or call start().")
|
|
248
|
+
return self._http.request(method, f"/v1/sessions/{self.session_id}{suffix}", body)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Ready-made tools for LLM agents: read files, write files, run commands — in a sandbox.
|
|
2
|
+
|
|
3
|
+
from airlock_sandbox import Sandbox
|
|
4
|
+
from airlock_sandbox.tools import openai_tools, handle_tool_call
|
|
5
|
+
|
|
6
|
+
with Sandbox() as sbx:
|
|
7
|
+
response = client.chat.completions.create(model=..., messages=..., tools=openai_tools())
|
|
8
|
+
for call in response.choices[0].message.tool_calls or []:
|
|
9
|
+
result = handle_tool_call(sbx, call.function.name, call.function.arguments)
|
|
10
|
+
|
|
11
|
+
`anthropic_tools()` gives the same tools in the Anthropic Messages API format.
|
|
12
|
+
handle_tool_call never raises: errors come back as text the model can act on.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any, Dict, List, Union
|
|
18
|
+
|
|
19
|
+
from airlock_sandbox.errors import SandboxError
|
|
20
|
+
|
|
21
|
+
MAX_TOOL_OUTPUT_CHARS = 8000
|
|
22
|
+
|
|
23
|
+
TOOLS: List[Dict[str, Any]] = [
|
|
24
|
+
{
|
|
25
|
+
"name": "write_file",
|
|
26
|
+
"description": "Create or overwrite a text file in the sandbox workspace.",
|
|
27
|
+
"parameters": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"path": {"type": "string", "description": "Path relative to the workspace, e.g. 'src/main.py'"},
|
|
31
|
+
"content": {"type": "string", "description": "Full file content"},
|
|
32
|
+
},
|
|
33
|
+
"required": ["path", "content"],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "read_file",
|
|
38
|
+
"description": "Read a text file from the sandbox workspace.",
|
|
39
|
+
"parameters": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {"path": {"type": "string", "description": "Path relative to the workspace"}},
|
|
42
|
+
"required": ["path"],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "run_command",
|
|
47
|
+
"description": (
|
|
48
|
+
"Run a shell command in an isolated Linux sandbox (bash, Python 3.11). Files in the workspace "
|
|
49
|
+
"persist between commands; processes do not. Returns stdout, stderr and the exit code."
|
|
50
|
+
),
|
|
51
|
+
"parameters": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"command": {"type": "string", "description": "Shell command, e.g. 'python3 -m pytest -q'"},
|
|
55
|
+
"timeout_seconds": {"type": "integer", "description": "1-60, default 15", "minimum": 1, "maximum": 60},
|
|
56
|
+
},
|
|
57
|
+
"required": ["command"],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def openai_tools() -> List[Dict[str, Any]]:
|
|
64
|
+
"""Tool definitions for OpenAI Chat Completions (`tools=`)."""
|
|
65
|
+
return [{"type": "function", "function": dict(t)} for t in TOOLS]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def anthropic_tools() -> List[Dict[str, Any]]:
|
|
69
|
+
"""Tool definitions for the Anthropic Messages API (`tools=`)."""
|
|
70
|
+
return [{"name": t["name"], "description": t["description"], "input_schema": t["parameters"]} for t in TOOLS]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def truncate(text: str, limit: int = MAX_TOOL_OUTPUT_CHARS) -> str:
|
|
74
|
+
"""Keeps the head and the tail; errors and exit codes are usually at the end."""
|
|
75
|
+
if len(text) <= limit:
|
|
76
|
+
return text
|
|
77
|
+
head = limit * 2 // 5
|
|
78
|
+
tail = limit - head
|
|
79
|
+
return f"{text[:head]}\n\n... [TRUNCATED: {len(text) - head - tail} characters omitted] ...\n\n{text[-tail:]}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def handle_tool_call(sandbox: Any, name: str, arguments: Union[str, Dict[str, Any], None]) -> str:
|
|
83
|
+
"""Runs one tool call against `sandbox` and returns text for the model. Never raises."""
|
|
84
|
+
try:
|
|
85
|
+
args = json.loads(arguments) if isinstance(arguments, str) else dict(arguments or {})
|
|
86
|
+
except (json.JSONDecodeError, TypeError, ValueError) as err:
|
|
87
|
+
return f"Error: tool arguments were not valid JSON: {err}"
|
|
88
|
+
if not isinstance(args, dict):
|
|
89
|
+
return "Error: tool arguments must be a JSON object."
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
if name == "write_file":
|
|
93
|
+
sandbox.files.write(args["path"], args["content"])
|
|
94
|
+
result = f"Wrote {len(args['content'])} characters to {args['path']}"
|
|
95
|
+
elif name == "read_file":
|
|
96
|
+
result = sandbox.files.read(args["path"])
|
|
97
|
+
elif name == "run_command":
|
|
98
|
+
result = sandbox.run(args["command"], timeout=int(args.get("timeout_seconds", 15))).output
|
|
99
|
+
else:
|
|
100
|
+
return f"Error: unknown tool '{name}'. Available: {[t['name'] for t in TOOLS]}"
|
|
101
|
+
except KeyError as err:
|
|
102
|
+
return f"Error: missing required argument {err} for {name}."
|
|
103
|
+
except FileNotFoundError as err:
|
|
104
|
+
return f"Error: file not found: {err}"
|
|
105
|
+
except SandboxError as err:
|
|
106
|
+
return f"Error from sandbox ({type(err).__name__}): {err}"
|
|
107
|
+
except Exception as err: # the model should see the problem, not crash the agent loop
|
|
108
|
+
return f"Error: {name} failed ({type(err).__name__}): {err}"
|
|
109
|
+
return truncate(result)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: airlock-sandbox
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for agent-sandbox: isolated, network-controlled code execution for AI agents.
|
|
5
|
+
Author: farvez
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/farvez/agent-sandbox
|
|
8
|
+
Project-URL: Source, https://github.com/farvez/agent-sandbox/tree/main/sdk/python
|
|
9
|
+
Project-URL: Issues, https://github.com/farvez/agent-sandbox/issues
|
|
10
|
+
Keywords: ai,agents,sandbox,code-execution,gvisor,llm,tools
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# airlock-sandbox
|
|
24
|
+
|
|
25
|
+
Python SDK for [agent-sandbox](https://github.com/farvez/agent-sandbox): run code from
|
|
26
|
+
AI agents in isolated gVisor containers — no network unless you allow it, hard memory,
|
|
27
|
+
CPU, process and disk limits, and every outbound connection logged.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install airlock-sandbox
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
No dependencies; Python 3.9+.
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from airlock_sandbox import Sandbox
|
|
39
|
+
|
|
40
|
+
with Sandbox(api_key="...", base_url="https://sandbox.example.com") as sbx:
|
|
41
|
+
sbx.files.write("main.py", "print(2 ** 16)")
|
|
42
|
+
result = sbx.run("python3 main.py")
|
|
43
|
+
print(result.stdout) # 65536
|
|
44
|
+
print(result.exit_code) # 0
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Leaving the `with` block deletes the session and its files. Settings can come from the
|
|
48
|
+
environment instead: `SANDBOX_API_URL`, `SANDBOX_API_KEY`, and `SANDBOX_API_INSECURE=1`
|
|
49
|
+
for a server with a self-signed certificate (or pass `verify="/path/to/ca.pem"`).
|
|
50
|
+
|
|
51
|
+
## Running commands
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
result = sbx.run("python3 -m pytest -q", timeout=60) # 1–60 s
|
|
55
|
+
result.stdout, result.stderr, result.exit_code
|
|
56
|
+
result.ok # exit code 0, not timed out, not killed
|
|
57
|
+
result.timed_out # hit the timeout
|
|
58
|
+
result.oom_killed # killed for exceeding the memory limit
|
|
59
|
+
result.warnings # e.g. over the disk quota
|
|
60
|
+
result.check() # raises CommandError unless ok; returns the result
|
|
61
|
+
str(result) # text form with [STDOUT]/[STDERR]/[EXIT CODE] — handy for an LLM
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each command runs in a fresh container; files in the workspace persist between
|
|
65
|
+
commands, processes do not.
|
|
66
|
+
|
|
67
|
+
## Internet access (egress)
|
|
68
|
+
|
|
69
|
+
Sandboxes have no network by default. Ask for specific hosts — they must be allowed by
|
|
70
|
+
your tenant's policy on the server:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
with Sandbox(egress=["pypi"]) as sbx: # presets: pypi, npm, github, huggingface
|
|
74
|
+
sbx.run("pip install requests", timeout=60).check()
|
|
75
|
+
for event in sbx.egress_log():
|
|
76
|
+
print(event["decision"], event["host"], event.get("reason", ""))
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Only HTTPS to the listed hosts works; everything else is refused and logged.
|
|
80
|
+
`sbx.egress_policy()` lists what your tenant may request.
|
|
81
|
+
|
|
82
|
+
## Use as tools for an LLM agent
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from airlock_sandbox import Sandbox
|
|
86
|
+
from airlock_sandbox.tools import openai_tools, anthropic_tools, handle_tool_call
|
|
87
|
+
|
|
88
|
+
# OpenAI
|
|
89
|
+
response = client.chat.completions.create(model="gpt-4o-mini", messages=messages, tools=openai_tools())
|
|
90
|
+
for call in response.choices[0].message.tool_calls or []:
|
|
91
|
+
output = handle_tool_call(sbx, call.function.name, call.function.arguments)
|
|
92
|
+
|
|
93
|
+
# Anthropic
|
|
94
|
+
response = client.messages.create(model="claude-sonnet-5", max_tokens=2048, messages=messages, tools=anthropic_tools())
|
|
95
|
+
for block in response.content:
|
|
96
|
+
if block.type == "tool_use":
|
|
97
|
+
output = handle_tool_call(sbx, block.name, block.input)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Tools: `write_file`, `read_file`, `run_command`. `handle_tool_call` never raises — errors
|
|
101
|
+
come back as text the model can act on — and long output is trimmed to 8,000 characters,
|
|
102
|
+
keeping the beginning and the end.
|
|
103
|
+
|
|
104
|
+
## Errors
|
|
105
|
+
|
|
106
|
+
All errors inherit from `SandboxError`:
|
|
107
|
+
|
|
108
|
+
| Exception | When |
|
|
109
|
+
|-----------|------|
|
|
110
|
+
| `AuthenticationError` | Missing or invalid API key (401) |
|
|
111
|
+
| `PermissionDeniedError` | Path outside the workspace, egress outside your policy, someone else's session (403) |
|
|
112
|
+
| `NotFoundError` | Session doesn't exist or expired (404) |
|
|
113
|
+
| `QuotaExceededError` | Write would exceed the workspace disk quota (413) |
|
|
114
|
+
| `RateLimitError` | Tenant limit hit: sessions, requests/minute or concurrent commands (429); see `.retry_after` |
|
|
115
|
+
| `CapacityError` | Every workspace on the server is in use (503) |
|
|
116
|
+
| `ValidationError` | Invalid request, e.g. unknown template (400/422) |
|
|
117
|
+
| `APIConnectionError` | Server unreachable |
|
|
118
|
+
| `CommandError` | Raised by `CommandResult.check()` |
|
|
119
|
+
|
|
120
|
+
Rate limits (429) and a full server (503) are retried automatically, honouring the
|
|
121
|
+
server's `Retry-After` (`max_retries=3` by default).
|
|
122
|
+
|
|
123
|
+
## Account
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
sbx.usage() # {"limits": {...}, "sessions_open": 1, "commands_running": 0, "requests_available": 118}
|
|
127
|
+
sbx.health()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/airlock_sandbox/__init__.py
|
|
5
|
+
src/airlock_sandbox/_http.py
|
|
6
|
+
src/airlock_sandbox/errors.py
|
|
7
|
+
src/airlock_sandbox/py.typed
|
|
8
|
+
src/airlock_sandbox/sandbox.py
|
|
9
|
+
src/airlock_sandbox/tools.py
|
|
10
|
+
src/airlock_sandbox.egg-info/PKG-INFO
|
|
11
|
+
src/airlock_sandbox.egg-info/SOURCES.txt
|
|
12
|
+
src/airlock_sandbox.egg-info/dependency_links.txt
|
|
13
|
+
src/airlock_sandbox.egg-info/top_level.txt
|
|
14
|
+
tests/test_offline.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
airlock_sandbox
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""SDK checks that need no server — CI runs these against the built wheel on several Python versions."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import airlock_sandbox
|
|
7
|
+
from airlock_sandbox import (
|
|
8
|
+
CommandError,
|
|
9
|
+
CommandResult,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
Sandbox,
|
|
12
|
+
SandboxError,
|
|
13
|
+
)
|
|
14
|
+
from airlock_sandbox.errors import STATUS_ERRORS
|
|
15
|
+
from airlock_sandbox.tools import anthropic_tools, handle_tool_call, openai_tools, truncate
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_version_and_public_api():
|
|
19
|
+
assert airlock_sandbox.__version__.count(".") == 2
|
|
20
|
+
for name in airlock_sandbox.__all__:
|
|
21
|
+
assert hasattr(airlock_sandbox, name), name
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_needs_url_and_key(monkeypatch):
|
|
25
|
+
monkeypatch.delenv("SANDBOX_API_URL", raising=False)
|
|
26
|
+
monkeypatch.delenv("SANDBOX_API_KEY", raising=False)
|
|
27
|
+
with pytest.raises(SandboxError, match="SANDBOX_API_KEY"):
|
|
28
|
+
Sandbox()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_status_codes_map_to_exceptions():
|
|
32
|
+
assert STATUS_ERRORS[429] is RateLimitError
|
|
33
|
+
assert all(issubclass(cls, SandboxError) for cls in STATUS_ERRORS.values())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_command_result_from_structured_response():
|
|
37
|
+
r = CommandResult.from_response({"command": "x", "stdout": "a\n", "stderr": "", "exit_code": 1,
|
|
38
|
+
"timed_out": False, "oom_killed": False, "warnings": [], "output": "..."})
|
|
39
|
+
assert not r.ok and r.stdout == "a\n"
|
|
40
|
+
with pytest.raises(CommandError):
|
|
41
|
+
r.check()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_command_result_from_text_only_response():
|
|
45
|
+
r = CommandResult.from_response({"command": "x", "output": "[STDOUT]:\nhi\n[EXIT CODE]: 0"})
|
|
46
|
+
assert r.ok and r.stdout == "hi\n"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_tool_schemas_serialise():
|
|
50
|
+
assert len(openai_tools()) == len(anthropic_tools()) == 3
|
|
51
|
+
json.dumps(openai_tools())
|
|
52
|
+
json.dumps(anthropic_tools())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_tool_errors_are_text_not_exceptions():
|
|
56
|
+
assert "unknown tool" in handle_tool_call(object(), "nope", {})
|
|
57
|
+
assert "not valid JSON" in handle_tool_call(object(), "run_command", "{")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_truncate_keeps_both_ends():
|
|
61
|
+
text = "start" + "x" * 20_000 + "end"
|
|
62
|
+
out = truncate(text, 1000)
|
|
63
|
+
assert out.startswith("start") and out.endswith("end") and "TRUNCATED" in out
|