lcode-cli 0.4.1__tar.gz → 0.5.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.
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/CHANGELOG.md +14 -1
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/PKG-INFO +3 -2
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/README.md +2 -1
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/SECURITY.md +7 -2
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/configuration.md +4 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/how-it-works.md +1 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/quickstart.md +1 -1
- lcode_cli-0.5.0/docs/sandbox.md +79 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/usage.md +8 -2
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/mkdocs.yml +1 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/agent.py +18 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/cli.py +24 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/config.py +10 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/repl.py +33 -0
- lcode_cli-0.5.0/src/lcode/sandbox.py +204 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/tools.py +60 -26
- lcode_cli-0.5.0/tests/test_sandbox.py +166 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.editorconfig +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.gitignore +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/AGENTS.md +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/LICENSE +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/bench.md +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/changelog.md +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/contributing.md +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/index.md +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/installation.md +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/mcp.md +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/models.md +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/install.sh +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/overrides/main.html +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/pyproject.toml +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/conftest.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_config.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_web.py +0 -0
- {lcode_cli-0.4.1 → lcode_cli-0.5.0}/uv.lock +0 -0
|
@@ -6,6 +6,18 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.5.0] - 2026-10-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- An optional sandbox for shell commands: with `lcode --sandbox` (or `sandbox = "docker"` /
|
|
14
|
+
`"podman"`), the model's commands run in a container that only sees the project folder, as your
|
|
15
|
+
user, with no network access unless you allow it (`/sandbox network on`, `sandbox_network`), no
|
|
16
|
+
Linux capabilities and a process limit. The file tools are limited to the project too. lcode builds
|
|
17
|
+
a default image with Python, Node.js, git and build tools on first use (`sandbox_image` for your
|
|
18
|
+
own). In `auto-edit` mode, sandboxed commands run without asking. If the sandbox can't start, lcode
|
|
19
|
+
stops instead of running commands without it.
|
|
20
|
+
|
|
9
21
|
## [0.4.1] - 2026-10-01
|
|
10
22
|
|
|
11
23
|
### Added
|
|
@@ -174,7 +186,8 @@ First public release.
|
|
|
174
186
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
175
187
|
- One-line installer for Linux and macOS.
|
|
176
188
|
|
|
177
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
189
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.5.0...HEAD
|
|
190
|
+
[0.5.0]: https://github.com/nasser1941/lcode/compare/v0.4.1...v0.5.0
|
|
178
191
|
[0.4.1]: https://github.com/nasser1941/lcode/compare/v0.4.0...v0.4.1
|
|
179
192
|
[0.4.0]: https://github.com/nasser1941/lcode/compare/v0.3.1...v0.4.0
|
|
180
193
|
[0.3.1]: https://github.com/nasser1941/lcode/compare/v0.3.0...v0.3.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -78,6 +78,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
78
78
|
real coding tasks on your machine.
|
|
79
79
|
- **Safe by default.** Every edit is shown as a diff and every command that isn't read-only needs
|
|
80
80
|
your approval. `auto-edit` and `yolo` modes when you want speed.
|
|
81
|
+
- **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
|
|
82
|
+
Podman) that only sees your project, without network access unless you allow it.
|
|
81
83
|
- **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
|
|
82
84
|
request's edits, new files and shell-command changes, without touching your git history.
|
|
83
85
|
- **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
|
|
@@ -184,7 +186,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
184
186
|
|
|
185
187
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
186
188
|
|
|
187
|
-
- An [optional sandbox for shell commands](https://github.com/nasser1941/lcode/issues/9)
|
|
188
189
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
189
190
|
|
|
190
191
|
## Contributing
|
|
@@ -44,6 +44,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
44
44
|
real coding tasks on your machine.
|
|
45
45
|
- **Safe by default.** Every edit is shown as a diff and every command that isn't read-only needs
|
|
46
46
|
your approval. `auto-edit` and `yolo` modes when you want speed.
|
|
47
|
+
- **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
|
|
48
|
+
Podman) that only sees your project, without network access unless you allow it.
|
|
47
49
|
- **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
|
|
48
50
|
request's edits, new files and shell-command changes, without touching your git history.
|
|
49
51
|
- **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
|
|
@@ -150,7 +152,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
150
152
|
|
|
151
153
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
152
154
|
|
|
153
|
-
- An [optional sandbox for shell commands](https://github.com/nasser1941/lcode/issues/9)
|
|
154
155
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
155
156
|
|
|
156
157
|
## Contributing
|
|
@@ -25,8 +25,13 @@ user, in your working directory**. It is not a sandbox. Keep in mind:
|
|
|
25
25
|
|
|
26
26
|
- In the default `ask` mode, every file edit and every shell command that isn't on the read-only
|
|
27
27
|
allowlist (`ls`, `cat`, `grep`, `git status`, …) is shown to you and needs your approval.
|
|
28
|
-
`auto-edit` skips approval for edits; `yolo` skips all approval — use it only
|
|
29
|
-
environments (a
|
|
28
|
+
`auto-edit` skips approval for edits; `yolo` skips all approval — use it only with the sandbox or
|
|
29
|
+
in disposable environments (a VM, a throwaway clone).
|
|
30
|
+
- The optional sandbox (`sandbox = "docker"` or `"podman"`, or `lcode --sandbox`) runs shell commands
|
|
31
|
+
in a container that sees only the project folder, as your user, without network access unless
|
|
32
|
+
allowed. The file tools are then limited to the project too. It doesn't cover web or MCP tools,
|
|
33
|
+
the project folder itself stays writable, and a container shares the host kernel; see
|
|
34
|
+
https://nasser1941.github.io/lcode/sandbox/ for its limits.
|
|
30
35
|
- Content the model reads (files, command output, search results and web pages) can contain prompt
|
|
31
36
|
injections that try to make it run harmful commands. lcode marks web content as untrusted, but
|
|
32
37
|
review commands before approving them.
|
|
@@ -25,6 +25,9 @@ lcode config path # print the file location
|
|
|
25
25
|
| `web` | `on` | Web search and page fetching: `on`, `ask` (before each search/website) or `off` |
|
|
26
26
|
| `search_backend` | `auto` | `auto`, `ollama`, `brave`, `tavily` or `searxng`; `auto` uses the first one configured |
|
|
27
27
|
| `searxng_url` | | Your SearXNG instance, e.g. `http://localhost:8888` |
|
|
28
|
+
| `sandbox` | `off` | Run shell commands in a container: `docker` or `podman` ([Sandbox](sandbox.md)) |
|
|
29
|
+
| `sandbox_image` | lcode's image | Container image for the sandbox; any image with bash and setsid |
|
|
30
|
+
| `sandbox_network` | `false` | Let commands in the sandbox use the network |
|
|
28
31
|
| `mcp_tools` | `auto` | How [MCP](mcp.md#context) tool definitions reach the model: `auto`, `direct` or `search` (on demand) |
|
|
29
32
|
| `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
|
|
30
33
|
|
|
@@ -50,6 +53,7 @@ Environment variables override the file, which is useful for one-off runs and CI
|
|
|
50
53
|
| `LCODE_KEEP_ALIVE` | `keep_alive` |
|
|
51
54
|
| `OLLAMA_HOST` | `ollama_host` (same variable the Ollama CLI uses) |
|
|
52
55
|
| `LCODE_WEB` | `web` |
|
|
56
|
+
| `LCODE_SANDBOX` | `sandbox` |
|
|
53
57
|
| `OLLAMA_API_KEY`, `BRAVE_API_KEY`, `TAVILY_API_KEY` | API key for that [search provider](usage.md#web-search) (never stored in the config file) |
|
|
54
58
|
| `LCODE_HOME` | where sessions and prompt history are stored |
|
|
55
59
|
|
|
@@ -77,6 +77,7 @@ the Ollama server you configure, it only contacts the web when the model searche
|
|
|
77
77
|
| `permissions.py` | Approval prompts and the read-only allowlist |
|
|
78
78
|
| `mcp/` | MCP client: stdio and HTTP transports, OAuth sign-in, server settings, the catalog, `lcode mcp` |
|
|
79
79
|
| `bench.py` | `lcode bench`: the benchmark tasks, their checks and the reports |
|
|
80
|
+
| `sandbox.py` | The optional container for shell commands |
|
|
80
81
|
| `checkpoints.py` | Snapshots before the model changes files, for `/undo` and `/rewind` |
|
|
81
82
|
| `catalog.py`, `models.toml` | Model catalog and memory estimates |
|
|
82
83
|
| `hardware.py` | NVIDIA / Apple Silicon detection |
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Sandbox
|
|
2
|
+
|
|
3
|
+
By default the model's shell commands run directly on your machine, as you. The permission prompts
|
|
4
|
+
let you check each command, but they're a guardrail, not isolation, and `yolo` mode removes even
|
|
5
|
+
that. The sandbox runs the commands in a container that can only see your project:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
lcode --sandbox # this session
|
|
9
|
+
lcode config set sandbox docker # every session (or podman)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The session's banner then shows `sandbox docker · lcode's image · no network`.
|
|
13
|
+
|
|
14
|
+
## What the container can and can't do
|
|
15
|
+
|
|
16
|
+
| | |
|
|
17
|
+
|---|---|
|
|
18
|
+
| **Sees** | Your project: the git repository you started lcode in (or the folder itself, outside a repository), mounted at the same path, read-write |
|
|
19
|
+
| **Doesn't see** | Anything else on your machine: your home folder, SSH keys, cloud credentials, other projects, the Docker socket |
|
|
20
|
+
| **Network** | None, unless you allow it (`/sandbox network on`, or `sandbox_network = true`) |
|
|
21
|
+
| **Runs as** | Your user ID, so files it creates belong to you; no Linux capabilities, no privilege escalation, at most 1,024 processes |
|
|
22
|
+
| **Lives** | For the session: started when lcode starts, removed when it exits. Containers left behind by a crash are removed the next time |
|
|
23
|
+
|
|
24
|
+
lcode's file tools (reading, editing, searching) run on your machine, but with the sandbox on they're
|
|
25
|
+
limited to the same project folder, so the model's whole world is the project.
|
|
26
|
+
|
|
27
|
+
## Approvals
|
|
28
|
+
|
|
29
|
+
With the sandbox on, `auto-edit` mode runs shell commands without asking too, since they can only
|
|
30
|
+
change the project, and [`/undo`](usage.md#undo-and-checkpoints) can take those changes back. `ask`
|
|
31
|
+
mode still asks before every command that isn't read-only. `yolo` asks for nothing, with or without
|
|
32
|
+
the sandbox.
|
|
33
|
+
|
|
34
|
+
## The image
|
|
35
|
+
|
|
36
|
+
lcode builds its default image the first time you use the sandbox (a minute or two): Python 3.12,
|
|
37
|
+
Node.js, git, build tools, ripgrep, curl and jq. Use your own image to match your project's toolchain:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
lcode config set sandbox_image node:22
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Any image with `bash` and `setsid` works. Installing packages (`pip install`, `npm install`) needs
|
|
44
|
+
the network: allow it with `/sandbox network on` for the session, or `sandbox_network = true`. The
|
|
45
|
+
model is told when a command failed because there's no network.
|
|
46
|
+
|
|
47
|
+
## Settings
|
|
48
|
+
|
|
49
|
+
| Setting | Default | |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `sandbox` | `off` | `docker` or `podman` to run commands in a container |
|
|
52
|
+
| `sandbox_image` | lcode's image | Any image with bash and setsid |
|
|
53
|
+
| `sandbox_network` | `false` | Let commands use the network |
|
|
54
|
+
|
|
55
|
+
In a session, `/sandbox` shows the sandbox and `/sandbox network on|off` changes network access (the
|
|
56
|
+
container restarts; files in the project stay). `lcode doctor` checks that Docker or Podman works.
|
|
57
|
+
|
|
58
|
+
## Requirements
|
|
59
|
+
|
|
60
|
+
- **Linux:** [Docker Engine](https://docs.docker.com/engine/install/) (your user in the `docker`
|
|
61
|
+
group) or [Podman](https://podman.io/docs/installation) (rootless; lcode passes `--userns keep-id`.
|
|
62
|
+
Podman is less tested than Docker, so reports are welcome).
|
|
63
|
+
- **macOS:** Docker Desktop, [OrbStack](https://orbstack.dev) or [colima](https://github.com/abiosoft/colima).
|
|
64
|
+
Your project must be in a folder they share with containers (your home folder is, by default).
|
|
65
|
+
|
|
66
|
+
If the sandbox is on but can't start, lcode stops instead of running commands without it, and says
|
|
67
|
+
how to fix it.
|
|
68
|
+
|
|
69
|
+
## What the sandbox doesn't cover
|
|
70
|
+
|
|
71
|
+
It isolates **shell commands**. Know its limits:
|
|
72
|
+
|
|
73
|
+
- The project folder is writable: a command can still delete or damage your project's files. Use git,
|
|
74
|
+
and `/undo` for changes since the request started.
|
|
75
|
+
- With the network on, commands can reach the internet, including to send out anything in the project.
|
|
76
|
+
- [Web search and page fetching](usage.md#web-search) and [MCP tools](mcp.md) run on your machine,
|
|
77
|
+
not in the container. They still need their own approval (MCP) or can be turned off (`--no-web`).
|
|
78
|
+
- A container is not a virtual machine: it shares your machine's kernel, so a kernel vulnerability
|
|
79
|
+
could let a process escape. For untrusted code you can't afford to run, use a separate VM.
|
|
@@ -25,6 +25,7 @@ lcode mcp [catalog|add|list] # connect MCP servers: Jira, GitHub, AWS, databases
|
|
|
25
25
|
| `--no-think` | Turn off the model's reasoning: faster, less accurate |
|
|
26
26
|
| `--no-web` | No web search or page fetching in this session |
|
|
27
27
|
| `--no-mcp` | Don't start [MCP servers](mcp.md) in this session |
|
|
28
|
+
| `--sandbox` | Run shell commands in a container that only sees the project ([Sandbox](sandbox.md)) |
|
|
28
29
|
| `--show-thinking` | Print the model's reasoning as it streams |
|
|
29
30
|
| `-V, --version` | Print the version |
|
|
30
31
|
|
|
@@ -55,6 +56,7 @@ reasoning is on.
|
|
|
55
56
|
| `/undo` | Undo the file changes of the last request ([details](#undo-and-checkpoints)) |
|
|
56
57
|
| `/rewind [N]` | Go back to before request N: its files, and optionally the conversation |
|
|
57
58
|
| `/checkpoints` | List the requests that changed files, and which files |
|
|
59
|
+
| `/sandbox [network on\|off]` | The [sandbox](sandbox.md) for shell commands |
|
|
58
60
|
| `/mcp [tools\|login\|restart NAME]` | [MCP servers](mcp.md), their status and tools |
|
|
59
61
|
| `/compact [focus]` | Summarize the conversation to free context |
|
|
60
62
|
| `/context [size]` | Show how full the context window is and change its size: pick from a list with memory estimates, or give a size like `/context 128k` (`/ctx` is a shortcut) |
|
|
@@ -101,8 +103,12 @@ or ++n++. Text after ++n++ goes to the model as instructions: `n run the tests w
|
|
|
101
103
|
|
|
102
104
|
!!! warning "yolo mode"
|
|
103
105
|
|
|
104
|
-
In `yolo` mode the model can run any command as your user. Use it
|
|
105
|
-
throwaway clone, not on a machine with data you care
|
|
106
|
+
In `yolo` mode the model can run any command as your user. Use it with the
|
|
107
|
+
[sandbox](sandbox.md), in a VM or in a throwaway clone, not on a machine with data you care
|
|
108
|
+
about.
|
|
109
|
+
|
|
110
|
+
With the [sandbox](sandbox.md) on, `auto-edit` mode runs shell commands without asking too: they can
|
|
111
|
+
only reach the project, and `/undo` can take their changes back.
|
|
106
112
|
|
|
107
113
|
## Undo and checkpoints
|
|
108
114
|
|
|
@@ -26,6 +26,7 @@ from lcode.mcp import McpManager
|
|
|
26
26
|
from lcode.ollama import Ollama, OllamaError
|
|
27
27
|
from lcode.permissions import Permissions
|
|
28
28
|
from lcode.render import MarkdownStreamer
|
|
29
|
+
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
29
30
|
from lcode.tools import (
|
|
30
31
|
SCHEMAS,
|
|
31
32
|
WEB_FETCH_SCHEMA,
|
|
@@ -140,6 +141,9 @@ class Settings:
|
|
|
140
141
|
search_backend: str = "auto"
|
|
141
142
|
searxng_url: str | None = None
|
|
142
143
|
checkpoints: bool = True # snapshot files before the model changes them, for /undo
|
|
144
|
+
sandbox: str = "off" # off, docker or podman: where the model's shell commands run
|
|
145
|
+
sandbox_image: str | None = None
|
|
146
|
+
sandbox_network: bool = False
|
|
143
147
|
|
|
144
148
|
|
|
145
149
|
class Agent:
|
|
@@ -153,6 +157,11 @@ class Agent:
|
|
|
153
157
|
self.session_id = self.new_session_id()
|
|
154
158
|
self.checkpoints = Checkpoints(self.console, settings.checkpoints)
|
|
155
159
|
self.mcp: McpManager | None = None # set by the CLI when MCP servers are configured
|
|
160
|
+
self.sandbox = (
|
|
161
|
+
Sandbox(settings.sandbox, settings.sandbox_image, settings.sandbox_network, self.console)
|
|
162
|
+
if settings.sandbox != "off"
|
|
163
|
+
else None
|
|
164
|
+
)
|
|
156
165
|
self._mcp_prompt = "" # the MCP part at the end of the system prompt
|
|
157
166
|
self.session_name = ""
|
|
158
167
|
self.session_title = ""
|
|
@@ -462,6 +471,15 @@ class Agent:
|
|
|
462
471
|
finally:
|
|
463
472
|
self.checkpoints.end_turn()
|
|
464
473
|
|
|
474
|
+
def sandbox_root(self) -> Path | None:
|
|
475
|
+
"""The folder the model is limited to while the sandbox is on (None when it's off)."""
|
|
476
|
+
if not self.sandbox:
|
|
477
|
+
return None
|
|
478
|
+
try:
|
|
479
|
+
return project_root(self.cwd)
|
|
480
|
+
except SandboxError:
|
|
481
|
+
return self.cwd
|
|
482
|
+
|
|
465
483
|
def checkpoint(self) -> None:
|
|
466
484
|
"""Called before the model changes files: snapshot them once per request, for /undo."""
|
|
467
485
|
self.checkpoints.before_change(self.cwd)
|
|
@@ -321,6 +321,15 @@ def cmd_doctor(args) -> None:
|
|
|
321
321
|
except ValueError as e:
|
|
322
322
|
line("MCP", str(e), False)
|
|
323
323
|
ok = False
|
|
324
|
+
if cfg["sandbox"] == "off":
|
|
325
|
+
line("Sandbox", "off (lcode config set sandbox docker)", True)
|
|
326
|
+
else:
|
|
327
|
+
from lcode.sandbox import engine_problem
|
|
328
|
+
|
|
329
|
+
problem = engine_problem(cfg["sandbox"])
|
|
330
|
+
network = "network on" if cfg["sandbox_network"] else "no network"
|
|
331
|
+
line("Sandbox", problem or f"{cfg['sandbox']} · {network}", problem is None)
|
|
332
|
+
ok &= problem is None
|
|
324
333
|
node = shutil.which("npx")
|
|
325
334
|
line("Node.js", "found" if node else "not found (some MCP servers need npx)", True if node else None)
|
|
326
335
|
sys.exit(0 if ok else 1)
|
|
@@ -485,8 +494,20 @@ def cmd_chat(args) -> None:
|
|
|
485
494
|
search_backend=cfg["search_backend"],
|
|
486
495
|
searxng_url=cfg["searxng_url"],
|
|
487
496
|
checkpoints=cfg["checkpoints"],
|
|
497
|
+
sandbox=(cfg["sandbox"] if cfg["sandbox"] != "off" else "docker") if args.sandbox else cfg["sandbox"],
|
|
498
|
+
sandbox_image=cfg["sandbox_image"],
|
|
499
|
+
sandbox_network=cfg["sandbox_network"],
|
|
488
500
|
)
|
|
489
501
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
502
|
+
if agent.sandbox:
|
|
503
|
+
# Never run commands unsandboxed when the user asked for a sandbox: stop here instead.
|
|
504
|
+
from lcode.sandbox import SandboxError
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
with console.status("Starting the sandbox…"):
|
|
508
|
+
agent.sandbox.ensure(cwd)
|
|
509
|
+
except SandboxError as e:
|
|
510
|
+
fail(f"the sandbox can't start: {e}\nTurn it off with: lcode config set sandbox off")
|
|
490
511
|
if not args.no_mcp:
|
|
491
512
|
from lcode.mcp.commands import start_session
|
|
492
513
|
|
|
@@ -496,6 +517,8 @@ def cmd_chat(args) -> None:
|
|
|
496
517
|
finally:
|
|
497
518
|
if agent.mcp:
|
|
498
519
|
agent.mcp.close()
|
|
520
|
+
if agent.sandbox:
|
|
521
|
+
agent.sandbox.stop()
|
|
499
522
|
|
|
500
523
|
|
|
501
524
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -522,6 +545,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
522
545
|
parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
|
|
523
546
|
parser.add_argument("--no-web", action="store_true", help="don't let the model search or fetch web pages")
|
|
524
547
|
parser.add_argument("--no-mcp", action="store_true", help="don't start MCP servers in this session")
|
|
548
|
+
parser.add_argument("--sandbox", action="store_true", help="run shell commands in a container (Docker or Podman)")
|
|
525
549
|
parser.add_argument("--show-thinking", action="store_true", help="print the model's reasoning as it streams")
|
|
526
550
|
parser.add_argument("-V", "--version", action="version", version=f"lcode {__version__}")
|
|
527
551
|
return parser
|
|
@@ -25,6 +25,7 @@ PERMISSION_MODES = ("ask", "auto-edit", "yolo")
|
|
|
25
25
|
WEB_MODES = ("on", "ask", "off")
|
|
26
26
|
SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
|
|
27
27
|
MCP_TOOL_MODES = ("auto", "direct", "search")
|
|
28
|
+
SANDBOX_ENGINES = ("off", "docker", "podman")
|
|
28
29
|
|
|
29
30
|
# key -> (default, type, help)
|
|
30
31
|
SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
@@ -39,6 +40,9 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
39
40
|
"search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
|
|
40
41
|
"searxng_url": (None, str, "your SearXNG instance, e.g. http://localhost:8888"),
|
|
41
42
|
"checkpoints": (True, bool, "snapshot files before the model changes them, so /undo can restore them"),
|
|
43
|
+
"sandbox": ("off", str, "run the model's shell commands in a container: off | docker | podman"),
|
|
44
|
+
"sandbox_image": (None, str, "container image for the sandbox (default: lcode's, built on first use)"),
|
|
45
|
+
"sandbox_network": (False, bool, "let commands in the sandbox use the network"),
|
|
42
46
|
"mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
|
|
43
47
|
}
|
|
44
48
|
ENV_OVERRIDES = {
|
|
@@ -48,6 +52,7 @@ ENV_OVERRIDES = {
|
|
|
48
52
|
"LCODE_KEEP_ALIVE": "keep_alive",
|
|
49
53
|
"OLLAMA_HOST": "ollama_host",
|
|
50
54
|
"LCODE_WEB": "web",
|
|
55
|
+
"LCODE_SANDBOX": "sandbox",
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
|
|
@@ -99,6 +104,11 @@ def coerce(key: str, value: object) -> object:
|
|
|
99
104
|
if value not in WEB_MODES:
|
|
100
105
|
raise ConfigError(f"web must be one of {', '.join(WEB_MODES)}")
|
|
101
106
|
return value
|
|
107
|
+
if key == "sandbox":
|
|
108
|
+
value = {"none": "off", "false": "off", "no": "off"}.get(str(value).lower(), str(value).lower())
|
|
109
|
+
if value not in SANDBOX_ENGINES:
|
|
110
|
+
raise ConfigError(f"sandbox must be one of {', '.join(SANDBOX_ENGINES)}")
|
|
111
|
+
return value
|
|
102
112
|
if key == "mcp_tools" and value not in MCP_TOOL_MODES:
|
|
103
113
|
raise ConfigError(f"mcp_tools must be one of {', '.join(MCP_TOOL_MODES)}")
|
|
104
114
|
if key == "search_backend" and value not in SEARCH_BACKENDS:
|
|
@@ -39,6 +39,7 @@ COMMANDS = {
|
|
|
39
39
|
"/undo": "Undo the file changes of the last request (lcode saves a checkpoint before changing files)",
|
|
40
40
|
"/rewind": "Go back to before an earlier request: its files, and optionally the conversation",
|
|
41
41
|
"/checkpoints": "List the requests that changed files, and which files",
|
|
42
|
+
"/sandbox": "Shell-command sandbox: status, or /sandbox network on|off",
|
|
42
43
|
"/mcp": "MCP servers and their tools: /mcp, /mcp tools NAME, /mcp login NAME, /mcp restart NAME",
|
|
43
44
|
"/compact": "Summarize the conversation to free context (optional: what to focus on)",
|
|
44
45
|
"/context": "Show context usage and change the window size: pick from a list, or /context 128k",
|
|
@@ -143,6 +144,11 @@ def banner(agent: Agent) -> None:
|
|
|
143
144
|
f"[dim]mode[/] {agent.perms.mode} [dim](Shift+Tab to cycle)[/]\n"
|
|
144
145
|
f"[dim]web[/] {web_status(agent)}\n"
|
|
145
146
|
+ (f"[dim]mcp[/] {escape(', '.join(agent.mcp.servers))} [dim](/mcp)[/]\n" if agent.mcp else "")
|
|
147
|
+
+ (
|
|
148
|
+
f"[dim]sandbox[/] {escape(agent.sandbox.describe())} [dim](/sandbox)[/]\n"
|
|
149
|
+
if agent.sandbox
|
|
150
|
+
else ""
|
|
151
|
+
)
|
|
146
152
|
+ "\n"
|
|
147
153
|
"[dim]/help for commands · @file to attach · Esc+Enter for a newline[/]"
|
|
148
154
|
),
|
|
@@ -240,6 +246,31 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
|
|
|
240
246
|
apply_context(agent, size, hardware)
|
|
241
247
|
|
|
242
248
|
|
|
249
|
+
def sandbox_command(agent: Agent, arg: str) -> None:
|
|
250
|
+
c, sandbox = agent.console, agent.sandbox
|
|
251
|
+
if sandbox is None:
|
|
252
|
+
c.print(
|
|
253
|
+
"The sandbox is off: shell commands run directly on this machine. Start lcode with --sandbox, or "
|
|
254
|
+
"turn it on for every session with: lcode config set sandbox docker"
|
|
255
|
+
)
|
|
256
|
+
return
|
|
257
|
+
words = arg.split()
|
|
258
|
+
if len(words) == 2 and words[0] == "network" and words[1] in ("on", "off"):
|
|
259
|
+
sandbox.network = words[1] == "on"
|
|
260
|
+
sandbox.stop() # the next command starts a container with the new setting
|
|
261
|
+
c.print(f"Network access in the sandbox is {words[1]} from the next command.")
|
|
262
|
+
return
|
|
263
|
+
if arg:
|
|
264
|
+
c.print("[yellow]Use /sandbox or /sandbox network on|off.[/]")
|
|
265
|
+
return
|
|
266
|
+
where = f" · container {sandbox.container}, mounting {sandbox.root}" if sandbox.container else ""
|
|
267
|
+
c.print(f"Sandbox: {escape(sandbox.describe())}{escape(where)}")
|
|
268
|
+
c.print(
|
|
269
|
+
"[dim]Shell commands run in the container and can only see this project; file tools are limited to it "
|
|
270
|
+
"too. In auto-edit mode, commands run without asking.[/]"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
243
274
|
def mcp_command(agent: Agent, arg: str) -> None:
|
|
244
275
|
from lcode.mcp.commands import status_table
|
|
245
276
|
from lcode.mcp.protocol import McpError
|
|
@@ -515,6 +546,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
515
546
|
print_checkpoints(agent)
|
|
516
547
|
elif cmd == "/mcp":
|
|
517
548
|
mcp_command(agent, arg)
|
|
549
|
+
elif cmd == "/sandbox":
|
|
550
|
+
sandbox_command(agent, arg)
|
|
518
551
|
elif cmd == "/compact":
|
|
519
552
|
agent.compact(arg)
|
|
520
553
|
elif cmd in ("/context", "/ctx"):
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""An optional sandbox: the model's shell commands run in a container that only sees the project.
|
|
2
|
+
|
|
3
|
+
One container per session. The project (the git repository around the working directory, or the
|
|
4
|
+
working directory itself) is mounted at the same path, so paths in command output stay valid and
|
|
5
|
+
lcode's file tools, which run on the host, see the same files. Commands run as the host user, with
|
|
6
|
+
no network unless it's allowed, no Linux capabilities, a process limit, and nothing else from the
|
|
7
|
+
host: not the home folder, not the Docker socket.
|
|
8
|
+
|
|
9
|
+
The default image is built locally on first use from the Dockerfile below. Any image with bash and
|
|
10
|
+
setsid (util-linux or busybox) can be used instead (`sandbox_image`).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import secrets
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
DOCKERFILE = """\
|
|
26
|
+
FROM python:3.12-slim-bookworm
|
|
27
|
+
RUN apt-get update \\
|
|
28
|
+
&& apt-get install -y --no-install-recommends \\
|
|
29
|
+
git curl ca-certificates build-essential ripgrep jq unzip less procps nodejs npm \\
|
|
30
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
31
|
+
"""
|
|
32
|
+
DEFAULT_IMAGE = f"lcode-sandbox:{hashlib.sha256(DOCKERFILE.encode()).hexdigest()[:12]}"
|
|
33
|
+
LABEL = "dev.lcode.sandbox"
|
|
34
|
+
HOME = "/tmp/lcode-home"
|
|
35
|
+
ENGINES = ("docker", "podman")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SandboxError(Exception):
|
|
39
|
+
"""The sandbox can't be used; the message says how to fix it."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def engine_problem(engine: str) -> str | None:
|
|
43
|
+
"""Why `engine` can't run containers here, or None if it can."""
|
|
44
|
+
if not shutil.which(engine):
|
|
45
|
+
if engine == "podman":
|
|
46
|
+
return "podman isn't installed (https://podman.io/docs/installation)"
|
|
47
|
+
if platform.system() == "Darwin":
|
|
48
|
+
return "docker isn't installed: install Docker Desktop, OrbStack or colima"
|
|
49
|
+
return "docker isn't installed (https://docs.docker.com/engine/install/)"
|
|
50
|
+
try:
|
|
51
|
+
r = subprocess.run(
|
|
52
|
+
[engine, "version", "--format", "{{.Server.Version}}"], capture_output=True, text=True, timeout=20
|
|
53
|
+
)
|
|
54
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
55
|
+
return f"{engine} doesn't respond: {e}"
|
|
56
|
+
if r.returncode != 0:
|
|
57
|
+
detail = (r.stderr.strip().splitlines() or [""])[-1]
|
|
58
|
+
if "permission denied" in detail.lower():
|
|
59
|
+
return (
|
|
60
|
+
f"{engine} needs permission: add yourself to the docker group "
|
|
61
|
+
"(sudo usermod -aG docker $USER, then log in again)"
|
|
62
|
+
)
|
|
63
|
+
if platform.system() == "Darwin":
|
|
64
|
+
return f"{engine} isn't running: start Docker Desktop, OrbStack or colima ({detail})"
|
|
65
|
+
return f"{engine} isn't running: start it (sudo systemctl start {engine}) ({detail})"
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def remove_orphans(engine: str) -> None:
|
|
70
|
+
"""Remove sandboxes left behind by lcode sessions that ended without cleaning up."""
|
|
71
|
+
try:
|
|
72
|
+
r = subprocess.run(
|
|
73
|
+
[engine, "ps", "-a", "--filter", f"label={LABEL}", "--format", '{{.ID}} {{.Label "dev.lcode.pid"}}'],
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
timeout=20,
|
|
77
|
+
)
|
|
78
|
+
except (OSError, subprocess.SubprocessError):
|
|
79
|
+
return
|
|
80
|
+
stale = []
|
|
81
|
+
for line in r.stdout.split("\n"):
|
|
82
|
+
parts = line.split()
|
|
83
|
+
if len(parts) == 2 and parts[1].isdigit() and not _alive(int(parts[1])):
|
|
84
|
+
stale.append(parts[0])
|
|
85
|
+
if stale:
|
|
86
|
+
subprocess.run([engine, "rm", "-f", *stale], capture_output=True, timeout=60)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _alive(pid: int) -> bool:
|
|
90
|
+
try:
|
|
91
|
+
os.kill(pid, 0)
|
|
92
|
+
except ProcessLookupError:
|
|
93
|
+
return False
|
|
94
|
+
except PermissionError:
|
|
95
|
+
return True
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Sandbox:
|
|
100
|
+
def __init__(self, engine: str, image: str | None = None, network: bool = False, console: Console | None = None):
|
|
101
|
+
if engine not in ENGINES:
|
|
102
|
+
raise SandboxError(f"unknown sandbox {engine!r}; use docker or podman")
|
|
103
|
+
self.engine = engine
|
|
104
|
+
self.image = image or DEFAULT_IMAGE
|
|
105
|
+
self.network = network
|
|
106
|
+
self.console = console or Console()
|
|
107
|
+
self.container: str | None = None
|
|
108
|
+
self.root: Path | None = None
|
|
109
|
+
|
|
110
|
+
# -- lifecycle
|
|
111
|
+
def check(self) -> None:
|
|
112
|
+
problem = engine_problem(self.engine)
|
|
113
|
+
if problem:
|
|
114
|
+
raise SandboxError(problem)
|
|
115
|
+
|
|
116
|
+
def contains(self, path: Path) -> bool:
|
|
117
|
+
return self.root is not None and (path == self.root or self.root in path.parents)
|
|
118
|
+
|
|
119
|
+
def ensure(self, cwd: Path) -> None:
|
|
120
|
+
"""Have a container running whose mounted folder includes `cwd`."""
|
|
121
|
+
if self.container and self.contains(cwd):
|
|
122
|
+
return
|
|
123
|
+
self.stop()
|
|
124
|
+
self.check()
|
|
125
|
+
remove_orphans(self.engine)
|
|
126
|
+
if self.image == DEFAULT_IMAGE:
|
|
127
|
+
self._build_default_image()
|
|
128
|
+
root = project_root(cwd)
|
|
129
|
+
name = f"lcode-sandbox-{os.getpid()}-{secrets.token_hex(3)}"
|
|
130
|
+
command = [
|
|
131
|
+
self.engine, "run", "--detach", "--init", "--name", name,
|
|
132
|
+
"--label", LABEL, "--label", f"dev.lcode.pid={os.getpid()}",
|
|
133
|
+
"--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "1024",
|
|
134
|
+
"--volume", f"{root}:{root}", "--workdir", str(cwd),
|
|
135
|
+
"--env", f"HOME={HOME}", "--tmpfs", f"{HOME}:exec,mode=1777",
|
|
136
|
+
] # fmt: skip
|
|
137
|
+
if not self.network:
|
|
138
|
+
command += ["--network", "none"]
|
|
139
|
+
if self.engine == "podman":
|
|
140
|
+
command += ["--userns", "keep-id"] # rootless podman: files belong to the host user
|
|
141
|
+
else:
|
|
142
|
+
command += ["--user", f"{os.getuid()}:{os.getgid()}"]
|
|
143
|
+
command += [self.image, "sleep", "infinity"]
|
|
144
|
+
r = subprocess.run(command, capture_output=True, text=True, timeout=300)
|
|
145
|
+
if r.returncode != 0:
|
|
146
|
+
raise SandboxError(f"couldn't start the sandbox: {(r.stderr.strip().splitlines() or ['?'])[-1]}")
|
|
147
|
+
self.container, self.root = name, root
|
|
148
|
+
|
|
149
|
+
def _build_default_image(self) -> None:
|
|
150
|
+
exists = subprocess.run([self.engine, "image", "inspect", self.image], capture_output=True, timeout=30)
|
|
151
|
+
if exists.returncode == 0:
|
|
152
|
+
return
|
|
153
|
+
with self.console.status("Building the sandbox image (once; a minute or two)…"):
|
|
154
|
+
r = subprocess.run(
|
|
155
|
+
[self.engine, "build", "--tag", self.image, "-"],
|
|
156
|
+
input=DOCKERFILE,
|
|
157
|
+
capture_output=True,
|
|
158
|
+
text=True,
|
|
159
|
+
timeout=1800,
|
|
160
|
+
)
|
|
161
|
+
if r.returncode != 0:
|
|
162
|
+
tail = "\n".join(r.stderr.strip().splitlines()[-5:])
|
|
163
|
+
raise SandboxError(f"couldn't build the sandbox image:\n{tail}")
|
|
164
|
+
|
|
165
|
+
def exec_argv(self, cwd: Path, script: str) -> tuple[list[str], str]:
|
|
166
|
+
"""The command that runs `script` with bash inside the container, and a token for kill()."""
|
|
167
|
+
assert self.container is not None
|
|
168
|
+
token = secrets.token_hex(6)
|
|
169
|
+
# Its own session, so a timeout or Ctrl+C can stop everything the command started.
|
|
170
|
+
script = f"echo $$ > /tmp/.lcode-{token}.pgid\n{script}"
|
|
171
|
+
argv = [self.engine, "exec", "--interactive", "--workdir", str(cwd), self.container]
|
|
172
|
+
return [*argv, "setsid", "--wait", "bash", "-c", script], token
|
|
173
|
+
|
|
174
|
+
def kill(self, token: str) -> None:
|
|
175
|
+
"""Stop a command and everything it started (after a timeout or Ctrl+C); the container stays."""
|
|
176
|
+
if self.container:
|
|
177
|
+
pgid = f"$(cat /tmp/.lcode-{token}.pgid 2>/dev/null)"
|
|
178
|
+
subprocess.run(
|
|
179
|
+
# bash, not sh: dash's kill doesn't accept "--" before a negative (process group) id
|
|
180
|
+
[self.engine, "exec", self.container, "bash", "-c", f'[ -n "{pgid}" ] && kill -KILL -- -{pgid}'],
|
|
181
|
+
capture_output=True,
|
|
182
|
+
timeout=30,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def stop(self) -> None:
|
|
186
|
+
if self.container:
|
|
187
|
+
subprocess.run([self.engine, "rm", "--force", self.container], capture_output=True, timeout=60)
|
|
188
|
+
self.container, self.root = None, None
|
|
189
|
+
|
|
190
|
+
def describe(self) -> str:
|
|
191
|
+
image = "lcode's image" if self.image == DEFAULT_IMAGE else self.image
|
|
192
|
+
network = "network on" if self.network else "no network"
|
|
193
|
+
return f"{self.engine} · {image} · {network}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def project_root(cwd: Path) -> Path:
|
|
197
|
+
"""The folder the sandbox mounts: the git repository around `cwd`, or `cwd` itself."""
|
|
198
|
+
from lcode.checkpoints import work_tree_for
|
|
199
|
+
|
|
200
|
+
root = work_tree_for(cwd)
|
|
201
|
+
home = Path.home().resolve()
|
|
202
|
+
if root == home or root in home.parents or root == Path("/"):
|
|
203
|
+
raise SandboxError(f"won't mount {root} into the sandbox; start lcode in a project folder")
|
|
204
|
+
return root
|