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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ 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