sanduk 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.
- sanduk-0.1.0/CHANGELOG.md +81 -0
- sanduk-0.1.0/LICENSE +22 -0
- sanduk-0.1.0/PKG-INFO +158 -0
- sanduk-0.1.0/README.md +138 -0
- sanduk-0.1.0/pyproject.toml +83 -0
- sanduk-0.1.0/pyproject.toml.orig +62 -0
- sanduk-0.1.0/src/sanduk/__init__.py +25 -0
- sanduk-0.1.0/src/sanduk/__main__.py +7 -0
- sanduk-0.1.0/src/sanduk/agent.py +110 -0
- sanduk-0.1.0/src/sanduk/cli.py +408 -0
- sanduk-0.1.0/src/sanduk/errors.py +15 -0
- sanduk-0.1.0/src/sanduk/preflight.py +92 -0
- sanduk-0.1.0/src/sanduk/proxy.py +354 -0
- sanduk-0.1.0/src/sanduk/py.typed +0 -0
- sanduk-0.1.0/src/sanduk/resources/Containerfile +18 -0
- sanduk-0.1.0/src/sanduk/runtime.py +239 -0
- sanduk-0.1.0/src/sanduk/util.py +20 -0
- sanduk-0.1.0/tests/test_cli.py +71 -0
- sanduk-0.1.0/tests/test_container.py +149 -0
- sanduk-0.1.0/tests/test_preflight.py +61 -0
- sanduk-0.1.0/tests/test_proxy.py +445 -0
- sanduk-0.1.0/tests/test_runtime.py +113 -0
- sanduk-0.1.0/tests/test_script.py +121 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Nothing is released yet; everything below is unreleased initial work.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.0]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `sanduk`: build a Linux VM through Apple `container`, run Claude Code headless in it against a bind-mounted directory, collect `REPORT.md`, delete the container. `--dry-run` prints the command instead.
|
|
12
|
+
|
|
13
|
+
- `--proxy`: run the agent on an `--internal` network with no route off the host, and relay its API calls through a host-side proxy that holds the key. The container gets a per-run token. Chosen over passing the key in as an environment variable because the container otherwise has a live credential and unrestricted egress, which makes "sandbox" true of the filesystem only. The relay is not optional overhead: on an egress-blocked network it is the container's only path to the API, so it must exist regardless, and injecting the key there costs two lines.
|
|
14
|
+
|
|
15
|
+
- `--allow-model` and `--max-tokens-cap`: model allowlist and token ceiling applied to request bodies at the relay. Enforced on the host, where the container cannot edit them, which is the point of doing it here rather than in the agent's flags.
|
|
16
|
+
|
|
17
|
+
- `tests/test_script.py` compares every function, method, and constant the standalone script and the package share, normalized for annotations and docstrings. Pinning the Containerfile alone was not enough: three relay fixes had landed in the package and not in the script, one of them a bodyless GET being dropped with no response. Eight names differ by design -- `die` against `AgentboxError`, `util.note`, and typing -- and are listed in the test; anything else fails it.
|
|
18
|
+
|
|
19
|
+
- `python3` in the agent image. Two runs in a row reached their verdict by hand-tracing rather than execution -- once against C with no compiler, once against a Python file with no interpreter -- and each spent turns discovering the absence before working around it. With an interpreter present the agent ports the file, runs it, and marks findings reproduced. Shipping a toolchain for every language the agent might meet is unbounded; one interpreter covers the common workload. It is not free: execution buys more turns, not fewer.
|
|
20
|
+
|
|
21
|
+
- Per-request token counts on the relay's log line: `in=`, `cache_write=`, `cache_read=`, `out=`. Both response shapes are read, because Claude Code sends `stream=false` and the API also streams: server-sent events carry usage in `message_start` and `message_delta`, a JSON body carries it once at the top level. Only SSE lines holding `"usage"` are parsed, so a stream is still forwarded chunk by chunk; a JSON body is buffered to 256KB and parsed at the end, since nothing in it can be read until it is whole. The request digest under `--log-bodies` gained `stream=` so the two are told apart without guessing. A `/v1/messages` response that yields no counts logs `usage=?` rather than a line that looks ordinary. Answers what the end-of-run total cannot: how much of each turn was a cache read, and so whether a second container reuses the first one's cached prefix.
|
|
22
|
+
|
|
23
|
+
- `--log-bodies`: record every request body the agent sends upstream. A digest line per call plus full JSON under `--log-dir`, which defaults outside the bind mount so the agent cannot read or edit its own audit trail. Bodies carry the system prompt and the contents of every file read, so they are written to files rather than to the terminal.
|
|
24
|
+
|
|
25
|
+
- Preflight validation of `ANTHROPIC_API_KEY` against the real endpoint before any container is created. A bad key inside the container costs 174s of SDK retry backoff before failing; the preflight rejects it in 0.27s.
|
|
26
|
+
|
|
27
|
+
- Preflight warning when the macOS application firewall has the running interpreter set to "Block incoming connections". That configuration drops the container's connection to the relay with no error, so the first API call hangs until `--timeout`. The check reads `socketfilterfw --listapps`, matches the framework `Resources/Python.app` path that `sys.executable` does not resolve to, and prints the exact `--unblockapp` command.
|
|
28
|
+
|
|
29
|
+
- `Makefile` and a pytest suite: 54 fast tests using a local fake upstream, 7 integration tests that boot real VMs. Neither makes an API call, so `make test` costs nothing and needs no key.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- `sanduk.py` is now the `sanduk` package under `src/`, installed as an `sanduk` console script. The 762-line script had one module for the CLI, the relay, the Apple `container` calls, and the Claude Code flags, which is exactly the shape that makes a second container engine or a second agent an edit through the middle of it. The pre-package script is kept at `scripts/sanduk.py`, which still runs standalone through its PEP 723 header. It now embeds the Containerfile as a raw string and writes it to a temporary build context when `--containerfile` is absent, so a copied script needs nothing beside it; an explicit `--containerfile` that is missing is still an error rather than a silent fall back. `tests/test_script.py` keeps the embedded copy byte-identical to `sanduk/resources/Containerfile`.
|
|
34
|
+
|
|
35
|
+
- Every call to a container engine moved behind `runtime.Runtime`, with `AppleContainer` the only implementation. `ContainerSpec` describes a container to run and `run_argv` renders it, so the placeholder container and the agent container go through the same code. A Docker or Podman subclass has to supply four things: the CLI name, the delete verb (`rm`, not `delete`), how `network inspect` reports the gateway, and whether the host bridge needs a placeholder container at all. Neither engine is installed here, so neither is written -- an untested backend is worse than an absent one.
|
|
36
|
+
|
|
37
|
+
- The Containerfile ships as package data at `sanduk/resources/Containerfile`, and `--containerfile` defaults to it. Previously the default was the string `"Containerfile"`, resolved against the working directory, so the tool only built an image when run from a checkout.
|
|
38
|
+
|
|
39
|
+
- `die()` became `AgentboxError`, and `main` returns an exit code instead of raising `SystemExit`. Library code that calls `sys.exit` cannot be embedded. The timeout path benefits directly: teardown caught `except SystemExit` and so also caught any unrelated `sys.exit` on the way out; it now catches the one exception it means.
|
|
40
|
+
|
|
41
|
+
- One Makefile. The packaging frontend and the container frontend both defined `build`, `rebuild`, `test`, and `clean` with different meanings. Image targets are now `image` and `image-rebuild`; `build` is the Python one; `clean` deletes containers and build artifacts; `distclean` adds the resolved environment, `destroy` adds the image, network, and logs. Help is generated from `##` comments rather than a hand-maintained echo list that drifts.
|
|
42
|
+
|
|
43
|
+
- pytest, ruff, and mypy configuration consolidated into `pyproject.toml`; `pytest.ini` deleted. Both files declared `testpaths`, and `pytest.ini` silently won.
|
|
44
|
+
|
|
45
|
+
- `requires-python` raised to 3.11, matching what the PEP 723 header already declared.
|
|
46
|
+
|
|
47
|
+
- Merged `keyproxy.py` into `sanduk.py`. The relay had no second consumer and no CLI of its own, and the split made `sanduk.py` fail with `ModuleNotFoundError` the moment it was copied anywhere without its sibling. Absolute-path and symlink invocation both happened to work, which is what made the failure easy to miss.
|
|
48
|
+
|
|
49
|
+
- The relay binds the network's bridge gateway rather than `0.0.0.0`. The wildcard bind put it on Wi-Fi and LAN as well. Because vmnet only creates the bridge while a container is attached, a placeholder container now holds the network up long enough to bind, and is torn down with the run.
|
|
50
|
+
|
|
51
|
+
- Dropped the relay's peer-subnet check. Once bound to the gateway it admitted the only caller class the bind does not already exclude: a host process reaching `192.168.128.1` presents source IP `192.168.128.1`, which is inside the subnet. Access control is the run token alone.
|
|
52
|
+
|
|
53
|
+
- The task prompt is no longer written to `TASK.md` on the mount. The agent found its own instructions there as a file and spent two of six turns identifying them, and pointing `-w` at a real repository dropped a file into it. The prompt already arrives via `-p`.
|
|
54
|
+
|
|
55
|
+
- Both scripts declare their interpreter with PEP 723 and `uv run --script`.
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- `scripts/agentbox.py` is `scripts/sanduk.py`. The rename to sanduk changed the file's contents but not its name, and `tests/test_script.py` finds it by path, so both drift tests skipped with the reason `scripts/ is not in this tree` -- which was false. The two tests that exist to catch a stale embedded Containerfile were themselves silently disabled.
|
|
60
|
+
|
|
61
|
+
- `--proxy`: the relay now offers only `gzip` upstream, for clients that already accept it. The API answers in brotli whenever a client lists it, Claude Code's does, and no standard-library module decodes brotli -- so the token counts above read compressed bytes and silently found nothing. Narrowing the offer keeps the response compressed and decodable; adding a brotli dependency to read a log line was the alternative. A client that asked for `identity`, or for something else entirely, still gets what it asked for.
|
|
62
|
+
|
|
63
|
+
- `--proxy`: a request with no body -- any GET, including `/v1/models`, which is on the default allowlist -- was dropped with the connection closed and no response. `apply_policy` returned the body it was given and `relay` read `None` back as "refused", so "there is nothing to check" and "this was rejected" were the same value. The refusal is now a separate flag. Every GET the suite covered stopped at a 401 or 403, so nothing reached the path that conflated them.
|
|
64
|
+
|
|
65
|
+
- Path allowlist matches `urlsplit(path).path` exactly instead of by prefix. Prefix matching admitted `/v1/models-internal-secret`; matching the raw path would have rejected `/v1/messages?beta=true`, which is what Claude Code actually calls. Both cases now have tests.
|
|
66
|
+
|
|
67
|
+
- The relay reads upstream with `read1`. `read(n)` blocks until `n` bytes arrive, which stalled every server-sent event behind a 64KB buffer.
|
|
68
|
+
|
|
69
|
+
- `--timeout` is enforced by a timer, not by a deadline checked inside the response loop. An agent that hangs without printing produces no lines, so the loop-checked deadline never fired.
|
|
70
|
+
|
|
71
|
+
- Teardown catches `SystemExit` as well as `KeyboardInterrupt`. A timeout kill exits through `die()` and previously skipped the delete, leaving a container alive holding the key.
|
|
72
|
+
|
|
73
|
+
- `validate_key` runs before the placeholder container is started. A rejected key used to leak that container.
|
|
74
|
+
|
|
75
|
+
- The token summary counts `cache_creation_input_tokens` and `cache_read_input_tokens`. A run billed at $0.23 was reported as 10 input tokens; 74% of its input was cache reads.
|
|
76
|
+
|
|
77
|
+
- `make clean` no longer deletes `sanduk-logs`. Recorded request bodies are evidence, not scratch; they move to `make destroy`, which reports the file count.
|
|
78
|
+
|
|
79
|
+
- `make destroy` is idempotent and no longer prints `Error 1 (ignored)` when the image or network is already gone.
|
|
80
|
+
|
|
81
|
+
- `make run` quotes `$(TASK)`. The default task is five words and was being split into five positional arguments.
|
sanduk-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
MIT License
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 Shakeeb Alireza
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
sanduk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: sanduk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run an agent inside a disposable container
|
|
5
|
+
Keywords: agent,claude,container,sandbox,proxy
|
|
6
|
+
Author: Shakeeb Alireza
|
|
7
|
+
Author-email: Shakeeb Alireza <shakfu@users.noreply.github.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# sanduk
|
|
22
|
+
|
|
23
|
+
Run an agent inside a disposable container. The agent does its work, writes a report to a bind-mounted directory, and the container is deleted.
|
|
24
|
+
|
|
25
|
+
Today that means Claude Code on macOS through Apple's [`container`](https://github.com/apple/container). The container engine sits behind `sanduk.runtime.Runtime` and the agent CLI behind `sanduk.agent`, so Docker, Podman, and other agents are additive.
|
|
26
|
+
|
|
27
|
+
In its stronger mode the VM has no route off the host and never holds the API key: a host-side relay injects the credential, and the container gets a per-run token that is worthless anywhere else.
|
|
28
|
+
|
|
29
|
+
'sanduk' means 'box' in Arabic.
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
- Mac with Apple silicon, macOS 26 or later (`container` requires both)
|
|
34
|
+
|
|
35
|
+
- [`container`](https://github.com/apple/container) 1.2.0 or later
|
|
36
|
+
|
|
37
|
+
- Python 3.11 or later, and `uv`
|
|
38
|
+
|
|
39
|
+
- An Anthropic API key in `ANTHROPIC_API_KEY`
|
|
40
|
+
|
|
41
|
+
Apple's `container` runs **Linux** containers as lightweight VMs. There is no such thing as a macOS container here; anything needing Xcode or the macOS toolchain cannot be the workload.
|
|
42
|
+
|
|
43
|
+
The image is `node:22-slim` plus `git`, `ripgrep`, `curl`, `jq`, and `python3`. The agent can only run what is in it. Without an interpreter it falls back to hand-tracing and still writes a confident report, so check whether the findings say they were reproduced. There is no C, Go, or Rust toolchain: point `--image` at your own, or `--containerfile` at one to build.
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
make sync
|
|
49
|
+
make image
|
|
50
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
51
|
+
make run TASK='Summarise every Python file here.' WORK=./work
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or directly, which is where all the flags live:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
uv run sanduk 'Summarise every Python file here.' -w ./work --proxy
|
|
58
|
+
uv run sanduk --help
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Two modes
|
|
62
|
+
|
|
63
|
+
| | default | `--proxy` |
|
|
64
|
+
| --------------------------------- | ---------------- | -------------------------------- |
|
|
65
|
+
| Host filesystem | VM isolation | VM isolation |
|
|
66
|
+
| API key location | inside the VM | host only; VM holds a run token |
|
|
67
|
+
| Egress | unrestricted | none |
|
|
68
|
+
| Agent can POST your source anywhere| yes | no |
|
|
69
|
+
| Which endpoints the agent may call | all | three, matched exactly |
|
|
70
|
+
| Record of what it sent upstream | none | `--log-bodies` |
|
|
71
|
+
|
|
72
|
+
The default mode is filesystem isolation and nothing more. `--proxy` is where the containment is.
|
|
73
|
+
|
|
74
|
+
## How --proxy works
|
|
75
|
+
|
|
76
|
+
1. `sanduk-net` is created with `--internal`: no route off the host.
|
|
77
|
+
|
|
78
|
+
2. vmnet only creates the host bridge while a container is attached, so a placeholder container is started first and torn down at the end.
|
|
79
|
+
|
|
80
|
+
3. The relay binds the bridge gateway only, so it is unreachable from Wi-Fi or LAN.
|
|
81
|
+
|
|
82
|
+
4. The container is given `ANTHROPIC_BASE_URL` and a per-run token as its `ANTHROPIC_API_KEY`. Both arrive through the child process environment, so neither appears in `ps`, and `container inspect` shows the token, not the key.
|
|
83
|
+
|
|
84
|
+
5. The relay checks the token, checks the path against an exact allowlist, applies any model or token policy, swaps in the real key, and streams the response back.
|
|
85
|
+
|
|
86
|
+
6. Every relayed call logs its token counts: `in=`, `cache_write=`, `cache_read=`, `out=`. A `/v1/messages` call that reports none logs `usage=?` rather than a line that looks ordinary. The relay narrows the client's `Accept-Encoding` to `gzip` to read them, because the API answers in brotli whenever a client offers it and nothing in the standard library decodes brotli.
|
|
87
|
+
|
|
88
|
+
## Flags worth knowing
|
|
89
|
+
|
|
90
|
+
`--allow-model claude-opus-5` and `--max-tokens-cap N` are enforced on the host, where the container cannot edit them. Claude Code asks for `max_tokens: 64000` on every call, so a cap below that silently truncates every request.
|
|
91
|
+
|
|
92
|
+
`--log-bodies` records each request body: a digest line per call, full JSON under `--log-dir` (default `./sanduk-logs`, deliberately outside the bind mount so the agent cannot read or edit its own audit trail). Bodies contain the system prompt and every file the agent has read.
|
|
93
|
+
|
|
94
|
+
`--dry-run` prints the `container run` command and exits. `--keep` leaves the container for inspection, and warns that `container inspect` then exposes the token.
|
|
95
|
+
|
|
96
|
+
## Layout
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
src/sanduk/
|
|
100
|
+
cli.py flags, lifecycle, teardown
|
|
101
|
+
runtime.py container engines; ContainerSpec; only `apple` is implemented
|
|
102
|
+
agent.py the agent CLI inside the container; only Claude Code
|
|
103
|
+
proxy.py the host-side relay
|
|
104
|
+
preflight.py key validation, macOS firewall check
|
|
105
|
+
resources/
|
|
106
|
+
Containerfile
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A second engine is a `Runtime` subclass and a `RUNTIMES` entry. It must supply four things: the CLI name, the verb that deletes a container (`rm`, not `delete`), how `network inspect` reports the gateway, and whether the host bridge needs a placeholder container to exist at all.
|
|
110
|
+
|
|
111
|
+
## Make targets
|
|
112
|
+
|
|
113
|
+
`make help` lists all of them. The ones you need:
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
make sync Resolve and install the environment
|
|
117
|
+
make test Fast suite: no containers, no API calls, no key needed
|
|
118
|
+
make test-container Integration suite: boots real containers
|
|
119
|
+
make test-all Both
|
|
120
|
+
make qa lint-check, format-check, typecheck, test
|
|
121
|
+
make image Build the agent image if missing
|
|
122
|
+
make image-rebuild Force a rebuild
|
|
123
|
+
make run TASK='...' WORK=./dir ARGS='--effort max'
|
|
124
|
+
make run-proxy Same, with no egress and the key held on the host
|
|
125
|
+
make shell Interactive shell in the image
|
|
126
|
+
make ps / make logs Containers / recorded request bodies
|
|
127
|
+
make stop Stop sanduk containers, leave them on disk
|
|
128
|
+
make clean Delete them and build scratch. Keeps work/ and logs
|
|
129
|
+
make distclean clean, plus .venv and tool caches
|
|
130
|
+
make destroy clean, plus the image, the network, and the logs
|
|
131
|
+
make system-start / system-stop / system-status
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`make system-stop` stops Apple's container service for everything on the machine, not just sanduk.
|
|
135
|
+
|
|
136
|
+
## Testing
|
|
137
|
+
|
|
138
|
+
The fast suite makes no API calls and needs no key: the relay is exercised against a local fake upstream, and the preflight is monkeypatched. `scripts/sanduk.py` carries its own copy of the relay, so a test compares every function the two share and fails on any difference outside a listed set of architectural ones. The integration suite boots real VMs and proves the relay by the 401 an invalid key earns from the real endpoint, which is itself proof the request arrived.
|
|
139
|
+
|
|
140
|
+
## Measured on this setup
|
|
141
|
+
|
|
142
|
+
| | |
|
|
143
|
+
| --- | --- |
|
|
144
|
+
| Bad key, host preflight | 0.27s |
|
|
145
|
+
| Bad key, no preflight | 174s of in-container retry backoff |
|
|
146
|
+
| Agent run, 3 files, 7 turns | 35.4s wall, 23.2s of it upstream |
|
|
147
|
+
| Review of one 59-line file, 10 turns | $0.55, 148s wall |
|
|
148
|
+
| Claude Code's system prompt and tool schemas | 22,993 tokens |
|
|
149
|
+
| That prefix written cold, as a share of one run | 25% of its cost |
|
|
150
|
+
| The same prefix on a second run inside the cache TTL | read, not written: 23% cheaper |
|
|
151
|
+
| Container direct egress on `sanduk-net` | `000` |
|
|
152
|
+
| Real key present in container environment | 0 occurrences |
|
|
153
|
+
|
|
154
|
+
## Known traps
|
|
155
|
+
|
|
156
|
+
The macOS application firewall silently drops connections to a binary set to "Block incoming connections", so the agent's first API call hangs until `--timeout` rather than failing. Homebrew's Python is shipped blocked on at least one machine; uv's interpreters are signed and auto-allowed. `--proxy` runs a preflight that names the exact `socketfilterfw --unblockapp` command when it sees an explicit block. It cannot detect an interpreter that will merely prompt.
|
|
157
|
+
|
|
158
|
+
`AF_UNIX` paths cap at 104 bytes on macOS, which matters if you point `--log-dir` somewhere deep.
|
sanduk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# sanduk
|
|
2
|
+
|
|
3
|
+
Run an agent inside a disposable container. The agent does its work, writes a report to a bind-mounted directory, and the container is deleted.
|
|
4
|
+
|
|
5
|
+
Today that means Claude Code on macOS through Apple's [`container`](https://github.com/apple/container). The container engine sits behind `sanduk.runtime.Runtime` and the agent CLI behind `sanduk.agent`, so Docker, Podman, and other agents are additive.
|
|
6
|
+
|
|
7
|
+
In its stronger mode the VM has no route off the host and never holds the API key: a host-side relay injects the credential, and the container gets a per-run token that is worthless anywhere else.
|
|
8
|
+
|
|
9
|
+
'sanduk' means 'box' in Arabic.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Mac with Apple silicon, macOS 26 or later (`container` requires both)
|
|
14
|
+
|
|
15
|
+
- [`container`](https://github.com/apple/container) 1.2.0 or later
|
|
16
|
+
|
|
17
|
+
- Python 3.11 or later, and `uv`
|
|
18
|
+
|
|
19
|
+
- An Anthropic API key in `ANTHROPIC_API_KEY`
|
|
20
|
+
|
|
21
|
+
Apple's `container` runs **Linux** containers as lightweight VMs. There is no such thing as a macOS container here; anything needing Xcode or the macOS toolchain cannot be the workload.
|
|
22
|
+
|
|
23
|
+
The image is `node:22-slim` plus `git`, `ripgrep`, `curl`, `jq`, and `python3`. The agent can only run what is in it. Without an interpreter it falls back to hand-tracing and still writes a confident report, so check whether the findings say they were reproduced. There is no C, Go, or Rust toolchain: point `--image` at your own, or `--containerfile` at one to build.
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
make sync
|
|
29
|
+
make image
|
|
30
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
31
|
+
make run TASK='Summarise every Python file here.' WORK=./work
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or directly, which is where all the flags live:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
uv run sanduk 'Summarise every Python file here.' -w ./work --proxy
|
|
38
|
+
uv run sanduk --help
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Two modes
|
|
42
|
+
|
|
43
|
+
| | default | `--proxy` |
|
|
44
|
+
| --------------------------------- | ---------------- | -------------------------------- |
|
|
45
|
+
| Host filesystem | VM isolation | VM isolation |
|
|
46
|
+
| API key location | inside the VM | host only; VM holds a run token |
|
|
47
|
+
| Egress | unrestricted | none |
|
|
48
|
+
| Agent can POST your source anywhere| yes | no |
|
|
49
|
+
| Which endpoints the agent may call | all | three, matched exactly |
|
|
50
|
+
| Record of what it sent upstream | none | `--log-bodies` |
|
|
51
|
+
|
|
52
|
+
The default mode is filesystem isolation and nothing more. `--proxy` is where the containment is.
|
|
53
|
+
|
|
54
|
+
## How --proxy works
|
|
55
|
+
|
|
56
|
+
1. `sanduk-net` is created with `--internal`: no route off the host.
|
|
57
|
+
|
|
58
|
+
2. vmnet only creates the host bridge while a container is attached, so a placeholder container is started first and torn down at the end.
|
|
59
|
+
|
|
60
|
+
3. The relay binds the bridge gateway only, so it is unreachable from Wi-Fi or LAN.
|
|
61
|
+
|
|
62
|
+
4. The container is given `ANTHROPIC_BASE_URL` and a per-run token as its `ANTHROPIC_API_KEY`. Both arrive through the child process environment, so neither appears in `ps`, and `container inspect` shows the token, not the key.
|
|
63
|
+
|
|
64
|
+
5. The relay checks the token, checks the path against an exact allowlist, applies any model or token policy, swaps in the real key, and streams the response back.
|
|
65
|
+
|
|
66
|
+
6. Every relayed call logs its token counts: `in=`, `cache_write=`, `cache_read=`, `out=`. A `/v1/messages` call that reports none logs `usage=?` rather than a line that looks ordinary. The relay narrows the client's `Accept-Encoding` to `gzip` to read them, because the API answers in brotli whenever a client offers it and nothing in the standard library decodes brotli.
|
|
67
|
+
|
|
68
|
+
## Flags worth knowing
|
|
69
|
+
|
|
70
|
+
`--allow-model claude-opus-5` and `--max-tokens-cap N` are enforced on the host, where the container cannot edit them. Claude Code asks for `max_tokens: 64000` on every call, so a cap below that silently truncates every request.
|
|
71
|
+
|
|
72
|
+
`--log-bodies` records each request body: a digest line per call, full JSON under `--log-dir` (default `./sanduk-logs`, deliberately outside the bind mount so the agent cannot read or edit its own audit trail). Bodies contain the system prompt and every file the agent has read.
|
|
73
|
+
|
|
74
|
+
`--dry-run` prints the `container run` command and exits. `--keep` leaves the container for inspection, and warns that `container inspect` then exposes the token.
|
|
75
|
+
|
|
76
|
+
## Layout
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
src/sanduk/
|
|
80
|
+
cli.py flags, lifecycle, teardown
|
|
81
|
+
runtime.py container engines; ContainerSpec; only `apple` is implemented
|
|
82
|
+
agent.py the agent CLI inside the container; only Claude Code
|
|
83
|
+
proxy.py the host-side relay
|
|
84
|
+
preflight.py key validation, macOS firewall check
|
|
85
|
+
resources/
|
|
86
|
+
Containerfile
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A second engine is a `Runtime` subclass and a `RUNTIMES` entry. It must supply four things: the CLI name, the verb that deletes a container (`rm`, not `delete`), how `network inspect` reports the gateway, and whether the host bridge needs a placeholder container to exist at all.
|
|
90
|
+
|
|
91
|
+
## Make targets
|
|
92
|
+
|
|
93
|
+
`make help` lists all of them. The ones you need:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
make sync Resolve and install the environment
|
|
97
|
+
make test Fast suite: no containers, no API calls, no key needed
|
|
98
|
+
make test-container Integration suite: boots real containers
|
|
99
|
+
make test-all Both
|
|
100
|
+
make qa lint-check, format-check, typecheck, test
|
|
101
|
+
make image Build the agent image if missing
|
|
102
|
+
make image-rebuild Force a rebuild
|
|
103
|
+
make run TASK='...' WORK=./dir ARGS='--effort max'
|
|
104
|
+
make run-proxy Same, with no egress and the key held on the host
|
|
105
|
+
make shell Interactive shell in the image
|
|
106
|
+
make ps / make logs Containers / recorded request bodies
|
|
107
|
+
make stop Stop sanduk containers, leave them on disk
|
|
108
|
+
make clean Delete them and build scratch. Keeps work/ and logs
|
|
109
|
+
make distclean clean, plus .venv and tool caches
|
|
110
|
+
make destroy clean, plus the image, the network, and the logs
|
|
111
|
+
make system-start / system-stop / system-status
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`make system-stop` stops Apple's container service for everything on the machine, not just sanduk.
|
|
115
|
+
|
|
116
|
+
## Testing
|
|
117
|
+
|
|
118
|
+
The fast suite makes no API calls and needs no key: the relay is exercised against a local fake upstream, and the preflight is monkeypatched. `scripts/sanduk.py` carries its own copy of the relay, so a test compares every function the two share and fails on any difference outside a listed set of architectural ones. The integration suite boots real VMs and proves the relay by the 401 an invalid key earns from the real endpoint, which is itself proof the request arrived.
|
|
119
|
+
|
|
120
|
+
## Measured on this setup
|
|
121
|
+
|
|
122
|
+
| | |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| Bad key, host preflight | 0.27s |
|
|
125
|
+
| Bad key, no preflight | 174s of in-container retry backoff |
|
|
126
|
+
| Agent run, 3 files, 7 turns | 35.4s wall, 23.2s of it upstream |
|
|
127
|
+
| Review of one 59-line file, 10 turns | $0.55, 148s wall |
|
|
128
|
+
| Claude Code's system prompt and tool schemas | 22,993 tokens |
|
|
129
|
+
| That prefix written cold, as a share of one run | 25% of its cost |
|
|
130
|
+
| The same prefix on a second run inside the cache TTL | read, not written: 23% cheaper |
|
|
131
|
+
| Container direct egress on `sanduk-net` | `000` |
|
|
132
|
+
| Real key present in container environment | 0 occurrences |
|
|
133
|
+
|
|
134
|
+
## Known traps
|
|
135
|
+
|
|
136
|
+
The macOS application firewall silently drops connections to a binary set to "Block incoming connections", so the agent's first API call hangs until `--timeout` rather than failing. Homebrew's Python is shipped blocked on at least one machine; uv's interpreters are signed and auto-allowed. `--proxy` runs a preflight that names the exact `socketfilterfw --unblockapp` command when it sees an explicit block. It cannot detect an interpreter that will merely prompt.
|
|
137
|
+
|
|
138
|
+
`AF_UNIX` paths cap at 104 bytes on macOS, which matters if you point `--log-dir` somewhere deep.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sanduk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Run an agent inside a disposable container"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
keywords = [
|
|
8
|
+
"agent",
|
|
9
|
+
"claude",
|
|
10
|
+
"container",
|
|
11
|
+
"sandbox",
|
|
12
|
+
"proxy",
|
|
13
|
+
]
|
|
14
|
+
dependencies = []
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.license]
|
|
28
|
+
text = "MIT"
|
|
29
|
+
|
|
30
|
+
[[project.authors]]
|
|
31
|
+
name = "Shakeeb Alireza"
|
|
32
|
+
email = "shakfu@users.noreply.github.com"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
sanduk = "sanduk.cli:main"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"mypy>=2.3.1",
|
|
40
|
+
"pytest>=9.1.1",
|
|
41
|
+
"pytest-cov>=7.1.0",
|
|
42
|
+
"ruff>=0.16.6",
|
|
43
|
+
"twine>=7.0.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["uv_build>=0.12.10,<0.13"]
|
|
48
|
+
build-backend = "uv_build"
|
|
49
|
+
|
|
50
|
+
[tool.uv.build-backend]
|
|
51
|
+
module-name = "sanduk"
|
|
52
|
+
module-root = "src"
|
|
53
|
+
source-include = [
|
|
54
|
+
"tests/**",
|
|
55
|
+
"CHANGELOG.md",
|
|
56
|
+
"LICENSE",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
addopts = "-m 'not container'"
|
|
62
|
+
markers = ["container: boots real containers; slow, needs the engine running"]
|
|
63
|
+
|
|
64
|
+
[tool.ruff]
|
|
65
|
+
src = [
|
|
66
|
+
"src",
|
|
67
|
+
"tests",
|
|
68
|
+
]
|
|
69
|
+
line-length = 90
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
select = [
|
|
73
|
+
"E",
|
|
74
|
+
"F",
|
|
75
|
+
"I",
|
|
76
|
+
"UP",
|
|
77
|
+
"B",
|
|
78
|
+
"SIM",
|
|
79
|
+
"RUF",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
[tool.mypy]
|
|
83
|
+
strict = true
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sanduk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Run an agent inside a disposable container"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Shakeeb Alireza", email = "shakfu@users.noreply.github.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
keywords = ["agent", "claude", "container", "sandbox", "proxy"]
|
|
12
|
+
# This project intentionally has no runtime dependencies.
|
|
13
|
+
dependencies = []
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
sanduk = "sanduk.cli:main"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"mypy>=2.3.1",
|
|
32
|
+
"pytest>=9.1.1",
|
|
33
|
+
"pytest-cov>=7.1.0",
|
|
34
|
+
"ruff>=0.16.6",
|
|
35
|
+
"twine>=7.0.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["uv_build>=0.12.10,<0.13"]
|
|
40
|
+
build-backend = "uv_build"
|
|
41
|
+
|
|
42
|
+
[tool.uv.build-backend]
|
|
43
|
+
module-name = "sanduk"
|
|
44
|
+
module-root = "src"
|
|
45
|
+
source-include = ["tests/**", "CHANGELOG.md", "LICENSE"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
addopts = "-m 'not container'"
|
|
50
|
+
markers = [
|
|
51
|
+
"container: boots real containers; slow, needs the engine running",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
src = ["src", "tests"]
|
|
56
|
+
line-length = 90
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
60
|
+
|
|
61
|
+
[tool.mypy]
|
|
62
|
+
strict = true
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Run an agent in a disposable container.
|
|
2
|
+
|
|
3
|
+
The agent works in a bind-mounted directory, writes a report, and the container
|
|
4
|
+
is deleted. With --proxy it runs on a network with no route off the host and
|
|
5
|
+
never holds the API key: a host-side relay injects the credential and the
|
|
6
|
+
container gets a per-run token.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from sanduk.agent import KEY_ENV, REPORT_NAME
|
|
10
|
+
from sanduk.cli import main
|
|
11
|
+
from sanduk.errors import AgentboxError
|
|
12
|
+
from sanduk.proxy import start_proxy
|
|
13
|
+
from sanduk.runtime import ContainerSpec, Runtime, get_runtime
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"KEY_ENV",
|
|
17
|
+
"REPORT_NAME",
|
|
18
|
+
"AgentboxError",
|
|
19
|
+
"ContainerSpec",
|
|
20
|
+
"Runtime",
|
|
21
|
+
"get_runtime",
|
|
22
|
+
"main",
|
|
23
|
+
"start_proxy",
|
|
24
|
+
]
|
|
25
|
+
__version__ = "0.1.0"
|